pi-mega-compact 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +95 -47
- package/dist/extensions/dashboard-server/api-contracts/index.js +1 -1
- package/dist/extensions/dashboard-server/api-contracts/turns.js +10 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
- package/dist/extensions/dashboard-server/routes-topics.js +87 -0
- package/dist/extensions/dashboard-server/routes-turns.js +251 -0
- package/dist/extensions/dashboard-server/routes.js +3 -1
- package/dist/extensions/dashboard-server/server.js +6 -2
- package/dist/extensions/mega-compact.js +10 -6
- package/dist/extensions/mega-config.js +4 -0
- package/dist/extensions/mega-events/agent-handlers.js +62 -49
- package/dist/extensions/mega-events/context-handler.js +48 -2
- package/dist/extensions/mega-events/session-handlers.js +24 -0
- package/dist/extensions/mega-metrics-cmds.js +90 -0
- package/dist/extensions/mega-pipeline/recall.js +47 -17
- package/dist/extensions/mega-topics-cmds.js +111 -0
- package/dist/extensions/mega-turn-store.js +110 -0
- package/dist/extensions/mega-turn-store.test.js +68 -0
- package/dist/src/config/turns.js +48 -0
- package/dist/src/fork.js +33 -0
- package/dist/src/fork.test.js +81 -0
- package/dist/src/intent.js +51 -0
- package/dist/src/intent.test.js +78 -0
- package/dist/src/metrics/index.js +4 -0
- package/dist/src/metrics/turns.js +118 -0
- package/dist/src/metrics/turns.test.js +152 -0
- package/dist/src/store/sqlite/raw-transcript.js +5 -3
- package/dist/src/store/sqlite.dbmirror.test.js +9 -0
- package/dist/src/store/turns/connection.js +123 -0
- package/dist/src/store/turns/contract-compliance.test.js +428 -0
- package/dist/src/store/turns/index.js +31 -0
- package/dist/src/store/turns/memory-store.js +357 -0
- package/dist/src/store/turns/memory-store.test.js +9 -0
- package/dist/src/store/turns/migrations.js +178 -0
- package/dist/src/store/turns/migrations.test.js +138 -0
- package/dist/src/store/turns/schema.js +115 -0
- package/dist/src/store/turns/sqlite-store.js +455 -0
- package/dist/src/store/turns/sqlite-store.test.js +144 -0
- package/dist/src/store/turns/types.js +16 -0
- package/dist/src/topics/cluster.js +141 -0
- package/dist/src/topics/cluster.test.js +150 -0
- package/dist/src/topics/index.js +6 -0
- package/dist/src/topics/kselection.js +142 -0
- package/dist/src/topics/labels.js +56 -0
- package/dist/src/topics/store.js +119 -0
- package/dist/src/topics/store.test.js +117 -0
- package/dist/src/topics/types.js +8 -0
- package/dist/src/wiki.js +133 -0
- package/dist/src/wiki.test.js +171 -0
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js → AchievementTiles-DUulvXUY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DUulvXUY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-Dx0YtCNf.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-Dx0YtCNf.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DtkaUo1v.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DtkaUo1v.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-CLa4KfiZ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-CLa4KfiZ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-Q6tEIBBi.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-Q6tEIBBi.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Hq7GNWSY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Hq7GNWSY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-BtmBV6lU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-BtmBV6lU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-CL10PwVU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-CL10PwVU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DAwANwbI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DAwANwbI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-Ec08TZRQ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-Ec08TZRQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-COb8dpTy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-COb8dpTy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js +2 -0
- package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js +2 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-UMrussFX.js} +10 -10
- package/extensions/dashboard-client/dist/assets/index-UMrussFX.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-og3ghxyY.css → index-gn66lYD2.css} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js → useSSE-BDHZ7CeS.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-BDHZ7CeS.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/App.tsx +10 -2
- package/extensions/dashboard-client/src/api/client.ts +84 -1
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/turns.css +142 -0
- package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +185 -0
- package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +326 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +389 -291
- package/extensions/dashboard-server/api-contracts/game-types.ts +141 -88
- package/extensions/dashboard-server/api-contracts/index.ts +153 -133
- package/extensions/dashboard-server/api-contracts/turns.ts +114 -0
- package/extensions/dashboard-server/api-contracts.test.ts +920 -919
- package/extensions/dashboard-server/routes-topics.ts +119 -0
- package/extensions/dashboard-server/routes-turns.ts +282 -0
- package/extensions/dashboard-server/routes.ts +8 -1
- package/extensions/dashboard-server/server.ts +6 -2
- package/extensions/mega-compact.ts +75 -59
- package/extensions/mega-config.ts +232 -220
- package/extensions/mega-events/agent-handlers.ts +194 -151
- package/extensions/mega-events/context-handler.ts +50 -1
- package/extensions/mega-events/session-handlers.ts +23 -0
- package/extensions/mega-metrics-cmds.ts +114 -0
- package/extensions/mega-pipeline/recall.ts +215 -152
- package/extensions/mega-topics-cmds.ts +151 -0
- package/extensions/mega-turn-store.test.ts +108 -0
- package/extensions/mega-turn-store.ts +169 -0
- package/package.json +3 -1
- package/src/config/turns.ts +69 -0
- package/src/fork.test.ts +94 -0
- package/src/fork.ts +78 -0
- package/src/intent.test.ts +87 -0
- package/src/intent.ts +115 -0
- package/src/metrics/index.ts +9 -0
- package/src/metrics/turns.test.ts +165 -0
- package/src/metrics/turns.ts +201 -0
- package/src/store/sqlite/raw-transcript.ts +9 -3
- package/src/store/sqlite.dbmirror.test.ts +10 -0
- package/src/store/turns/connection.ts +137 -0
- package/src/store/turns/contract-compliance.test.ts +562 -0
- package/src/store/turns/index.ts +65 -0
- package/src/store/turns/memory-store.test.ts +15 -0
- package/src/store/turns/memory-store.ts +469 -0
- package/src/store/turns/migrations.test.ts +161 -0
- package/src/store/turns/migrations.ts +203 -0
- package/src/store/turns/schema.ts +158 -0
- package/src/store/turns/sqlite-store.test.ts +161 -0
- package/src/store/turns/sqlite-store.ts +656 -0
- package/src/store/turns/types.ts +181 -0
- package/src/topics/cluster.test.ts +176 -0
- package/src/topics/cluster.ts +191 -0
- package/src/topics/index.ts +18 -0
- package/src/topics/kselection.ts +182 -0
- package/src/topics/labels.ts +67 -0
- package/src/topics/store.test.ts +132 -0
- package/src/topics/store.ts +208 -0
- package/src/topics/types.ts +64 -0
- package/src/wiki.test.ts +217 -0
- package/src/wiki.ts +198 -0
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
- package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sqlite-store.test.ts — SqliteTurnStore compliance + SQLite-specific tests.
|
|
3
|
+
*
|
|
4
|
+
* Compliance: shared suite run in-memory (fast).
|
|
5
|
+
* SQLite-specific: file-backed tests verifying:
|
|
6
|
+
* - Separate turns.db (not sqlite.db)
|
|
7
|
+
* - WAL mode active
|
|
8
|
+
* - Connection caching (same path → same connection)
|
|
9
|
+
* - dbSizeBytes reports file size
|
|
10
|
+
* - File persistence across store instances
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { existsSync, statSync, rmSync, mkdtempSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { SqliteTurnStore } from "./sqlite-store.js";
|
|
18
|
+
import { runComplianceSuite } from "./contract-compliance.test.js";
|
|
19
|
+
import { closeAllTurnDbs } from "./connection.js";
|
|
20
|
+
// ── Compliance suite (in-memory, fast) ──────────────────────────
|
|
21
|
+
runComplianceSuite("SqliteTurnStore", (options) => new SqliteTurnStore(options), { stateDir: join(tmpdir(), "turns-compliance-sqlite"), inMemory: true });
|
|
22
|
+
// ── SQLite-specific file-backed tests ───────────────────────────
|
|
23
|
+
describe("SqliteTurnStore — file-backed", () => {
|
|
24
|
+
let stateDir;
|
|
25
|
+
let store;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
closeAllTurnDbs(); // clear any cached connections
|
|
28
|
+
stateDir = mkdtempSync(join(tmpdir(), "turns-file-"));
|
|
29
|
+
store = new SqliteTurnStore({ stateDir });
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
try {
|
|
33
|
+
store.close();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// best-effort
|
|
37
|
+
}
|
|
38
|
+
closeAllTurnDbs();
|
|
39
|
+
try {
|
|
40
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// best-effort
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
it("creates turns.db separate from sqlite.db", () => {
|
|
47
|
+
const turnsDbPath = join(stateDir, "turns.db");
|
|
48
|
+
assert.ok(existsSync(turnsDbPath), "turns.db should exist");
|
|
49
|
+
// sqlite.db should NOT be created by the turns store
|
|
50
|
+
const sqliteDbPath = join(stateDir, "sqlite.db");
|
|
51
|
+
assert.ok(!existsSync(sqliteDbPath), "sqlite.db should NOT be created");
|
|
52
|
+
});
|
|
53
|
+
it("uses WAL journal mode", () => {
|
|
54
|
+
const row = store
|
|
55
|
+
.db.prepare("PRAGMA journal_mode")
|
|
56
|
+
.get();
|
|
57
|
+
assert.equal(row.journal_mode, "wal");
|
|
58
|
+
});
|
|
59
|
+
it("caches connections for the same path", () => {
|
|
60
|
+
// Creating a second store with the same stateDir should reuse
|
|
61
|
+
// the cached connection (not create a new file lock conflict)
|
|
62
|
+
const store2 = new SqliteTurnStore({ stateDir });
|
|
63
|
+
try {
|
|
64
|
+
// Both should be able to write without locking errors
|
|
65
|
+
const entry = {
|
|
66
|
+
conversationId: "conv_cache",
|
|
67
|
+
sessionId: "sess_cache",
|
|
68
|
+
turnIndex: 0,
|
|
69
|
+
role: "assistant",
|
|
70
|
+
endedAt: Date.now(),
|
|
71
|
+
};
|
|
72
|
+
store.asWriter().appendTurn(entry);
|
|
73
|
+
store2.asWriter().appendTurn({
|
|
74
|
+
...entry,
|
|
75
|
+
turnIndex: 1,
|
|
76
|
+
});
|
|
77
|
+
assert.equal(store.asReader().countTurns("conv_cache"), 2);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
store2.close();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
it("reports dbSizeBytes for file-backed store", () => {
|
|
84
|
+
const entry = {
|
|
85
|
+
conversationId: "conv_size",
|
|
86
|
+
sessionId: "sess_size",
|
|
87
|
+
turnIndex: 0,
|
|
88
|
+
role: "assistant",
|
|
89
|
+
endedAt: Date.now(),
|
|
90
|
+
};
|
|
91
|
+
store.asWriter().appendTurn(entry);
|
|
92
|
+
// prune returns a PruneReport with freedBytes; we can also
|
|
93
|
+
// verify the file exists and has content
|
|
94
|
+
const turnsDbPath = join(stateDir, "turns.db");
|
|
95
|
+
const size = statSync(turnsDbPath).size;
|
|
96
|
+
assert.ok(size > 0, "turns.db should have non-zero size after writes");
|
|
97
|
+
});
|
|
98
|
+
it("persists data across store instances", () => {
|
|
99
|
+
const entry = {
|
|
100
|
+
conversationId: "conv_persist",
|
|
101
|
+
sessionId: "sess_persist",
|
|
102
|
+
turnIndex: 0,
|
|
103
|
+
role: "assistant",
|
|
104
|
+
endedAt: Date.now(),
|
|
105
|
+
model: "test-model",
|
|
106
|
+
};
|
|
107
|
+
store.asWriter().appendTurn(entry);
|
|
108
|
+
store.close();
|
|
109
|
+
closeAllTurnDbs();
|
|
110
|
+
// Re-open the same stateDir
|
|
111
|
+
const store2 = new SqliteTurnStore({ stateDir });
|
|
112
|
+
try {
|
|
113
|
+
const turns = store2.asReader().query({ conversationId: "conv_persist" });
|
|
114
|
+
assert.equal(turns.length, 1);
|
|
115
|
+
assert.equal(turns[0].model, "test-model");
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
store2.close();
|
|
119
|
+
closeAllTurnDbs();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
it("supports custom dbPath", () => {
|
|
123
|
+
const customPath = join(stateDir, "custom-turns.db");
|
|
124
|
+
const customStore = new SqliteTurnStore({
|
|
125
|
+
stateDir,
|
|
126
|
+
dbPath: customPath,
|
|
127
|
+
});
|
|
128
|
+
try {
|
|
129
|
+
assert.ok(existsSync(customPath), "custom db path should be created");
|
|
130
|
+
customStore.asWriter().appendTurn({
|
|
131
|
+
conversationId: "conv_custom",
|
|
132
|
+
sessionId: "sess_custom",
|
|
133
|
+
turnIndex: 0,
|
|
134
|
+
role: "user",
|
|
135
|
+
endedAt: Date.now(),
|
|
136
|
+
});
|
|
137
|
+
assert.equal(customStore.asReader().countTurns("conv_custom"), 1);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
customStore.close();
|
|
141
|
+
closeAllTurnDbs();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — S49 contract module (the source of truth).
|
|
3
|
+
*
|
|
4
|
+
* Every implementation must satisfy these interfaces. Hosts import only this
|
|
5
|
+
* file + the factory from index.ts. SQL schemas are private to implementations.
|
|
6
|
+
*
|
|
7
|
+
* Design principles (from docs/specs/s49-rev1-architecture-upgrade.md):
|
|
8
|
+
* 1. Contract-first — the interface IS the spec.
|
|
9
|
+
* 2. Append-only — TurnWriter.append* are the only write methods; no UPDATE.
|
|
10
|
+
* 3. Capability-gated — asReader/asWriter/asAdmin return subset views.
|
|
11
|
+
* 4. Ledger protocol — host PUSHES facts, PULLS views. Store never initiates.
|
|
12
|
+
* 5. StoreSnapshot — checkpoint/restore for backup, migration, test seeding.
|
|
13
|
+
*
|
|
14
|
+
* PREVENT-PI-004: zero network. PREVENT-002: no SQL here (private to impls).
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cluster.ts — S51A k-means topic clustering over real stored embeddings.
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic (no pi imports), pure local math (PREVENT-PI-004): clusters the
|
|
5
|
+
* real `context_chunks.embedding_blob` vectors with k-means++ (reused from
|
|
6
|
+
* src/dedup/raptor/kmeans.ts), picks k by a real criterion (elbow on WCSS, or
|
|
7
|
+
* silhouette when the corpus is large enough), and labels each cluster with the
|
|
8
|
+
* top TF-IDF terms of its real member text. Pure math only — no model calls,
|
|
9
|
+
* no network, no remote service (PREVENT-PI-004).
|
|
10
|
+
*
|
|
11
|
+
* The topic view is derived + read-only over `context_chunks` — building a model
|
|
12
|
+
* never mutates any memory (PREVENT-PI-001/002). Parameterized SQL (PREVENT-002).
|
|
13
|
+
*/
|
|
14
|
+
import { cosineSimilarity } from "../embedder.js";
|
|
15
|
+
import { decodeEmbedding } from "../store/sqlite/utils.js";
|
|
16
|
+
import { tfidfScores, labelFromScores, membershipConfidence, } from "./labels.js";
|
|
17
|
+
import { selectK } from "./kselection.js";
|
|
18
|
+
export const DEFAULT_CLUSTER_CONFIG = {
|
|
19
|
+
kRange: [3, 15],
|
|
20
|
+
labelTopTerms: 5,
|
|
21
|
+
restarts: 5,
|
|
22
|
+
seed: 0x9e3779b9,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Load chunks that have a usable embedding. Text is the best-available surface
|
|
26
|
+
* (normalized_text → summary → topic_summary). Skips rows with no embedding or
|
|
27
|
+
* no text. Returns in stable (session_id, id) order for determinism.
|
|
28
|
+
*/
|
|
29
|
+
export function loadEmbeddings(mainDb) {
|
|
30
|
+
const rows = mainDb
|
|
31
|
+
.prepare(`SELECT id, session_id, embedding_blob,
|
|
32
|
+
COALESCE(normalized_text, summary, topic_summary) AS text
|
|
33
|
+
FROM context_chunks
|
|
34
|
+
WHERE embedding_blob IS NOT NULL
|
|
35
|
+
ORDER BY session_id ASC, id ASC`)
|
|
36
|
+
.all();
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const r of rows) {
|
|
39
|
+
const vec = decodeEmbedding(r.embedding_blob);
|
|
40
|
+
if (vec.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const text = (r.text ?? "").trim();
|
|
43
|
+
if (text.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
out.push({ chunkId: r.id, sessionId: r.session_id, vec, text });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build a topic model from the real stored chunks. Picks k by silhouette when
|
|
51
|
+
* computable, else elbow; degenerate/small corpus → single `general` cluster.
|
|
52
|
+
* Never throws on degenerate input — returns the safe single-cluster model.
|
|
53
|
+
*/
|
|
54
|
+
export function buildTopicModel(mainDb, cfg = DEFAULT_CLUSTER_CONFIG) {
|
|
55
|
+
const builtAt = Date.now();
|
|
56
|
+
const chunks = loadEmbeddings(mainDb);
|
|
57
|
+
const totalChunks = chunks.length;
|
|
58
|
+
// Too small to cluster meaningfully → single general cluster.
|
|
59
|
+
if (totalChunks < Math.max(2, cfg.kRange[0])) {
|
|
60
|
+
return singleClusterModel(chunks, builtAt, cfg);
|
|
61
|
+
}
|
|
62
|
+
const points = chunks.map((c) => c.vec);
|
|
63
|
+
const [minK, maxK] = cfg.kRange;
|
|
64
|
+
const hi = Math.min(maxK, Math.max(minK, Math.floor(totalChunks / 2)));
|
|
65
|
+
const ks = [];
|
|
66
|
+
for (let k = minK; k <= hi; k++)
|
|
67
|
+
ks.push(k);
|
|
68
|
+
if (ks.length === 0)
|
|
69
|
+
ks.push(minK);
|
|
70
|
+
const { candidate: chosen, criterion } = selectK(points, ks, cfg);
|
|
71
|
+
const meanSil = chosen.silhouette;
|
|
72
|
+
return assembleModel(chunks, chosen, criterion, meanSil, builtAt, cfg);
|
|
73
|
+
}
|
|
74
|
+
/** Single `general` cluster holding every chunk (degenerate/small corpus). */
|
|
75
|
+
function singleClusterModel(chunks, builtAt, cfg) {
|
|
76
|
+
const topic = {
|
|
77
|
+
id: "topic_0",
|
|
78
|
+
label: labelFromScores(tfidfScores(chunks, chunks), cfg.labelTopTerms),
|
|
79
|
+
termScores: tfidfScores(chunks, chunks),
|
|
80
|
+
memoryCount: chunks.length,
|
|
81
|
+
lastUpdated: builtAt,
|
|
82
|
+
};
|
|
83
|
+
const assignments = chunks.map((c) => ({
|
|
84
|
+
memoryId: c.chunkId,
|
|
85
|
+
sessionId: c.sessionId,
|
|
86
|
+
topicId: "topic_0",
|
|
87
|
+
confidence: 1,
|
|
88
|
+
assignedAt: builtAt,
|
|
89
|
+
method: "kmeans+tfidf",
|
|
90
|
+
}));
|
|
91
|
+
return {
|
|
92
|
+
topics: chunks.length > 0 ? [topic] : [],
|
|
93
|
+
assignments,
|
|
94
|
+
k: chunks.length > 0 ? 1 : 0,
|
|
95
|
+
criterion: "elbow",
|
|
96
|
+
silhouetteScore: null,
|
|
97
|
+
totalChunks: chunks.length,
|
|
98
|
+
builtAt,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Assemble topics + assignments from a chosen clustering. */
|
|
102
|
+
function assembleModel(chunks, chosen, criterion, meanSil, builtAt, cfg) {
|
|
103
|
+
const topics = [];
|
|
104
|
+
const assignments = [];
|
|
105
|
+
for (let c = 0; c < chosen.k; c++) {
|
|
106
|
+
const members = chunks.filter((_, i) => chosen.assignments[i] === c);
|
|
107
|
+
if (members.length === 0)
|
|
108
|
+
continue;
|
|
109
|
+
const id = `topic_${topics.length}`;
|
|
110
|
+
const scores = tfidfScores(members, chunks);
|
|
111
|
+
topics.push({
|
|
112
|
+
id,
|
|
113
|
+
label: labelFromScores(scores, cfg.labelTopTerms),
|
|
114
|
+
termScores: scores,
|
|
115
|
+
memoryCount: members.length,
|
|
116
|
+
lastUpdated: builtAt,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
120
|
+
const cluster = chosen.assignments[i];
|
|
121
|
+
const centroid = chosen.centroids[cluster];
|
|
122
|
+
const sim = centroid ? cosineSimilarity(chunks[i].vec, centroid) : 0;
|
|
123
|
+
assignments.push({
|
|
124
|
+
memoryId: chunks[i].chunkId,
|
|
125
|
+
sessionId: chunks[i].sessionId,
|
|
126
|
+
topicId: `topic_${cluster}`,
|
|
127
|
+
confidence: membershipConfidence(sim),
|
|
128
|
+
assignedAt: builtAt,
|
|
129
|
+
method: "kmeans+tfidf",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
topics,
|
|
134
|
+
assignments,
|
|
135
|
+
k: topics.length,
|
|
136
|
+
criterion,
|
|
137
|
+
silhouetteScore: meanSil,
|
|
138
|
+
totalChunks: chunks.length,
|
|
139
|
+
builtAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cluster.test.ts — S51A clustering + labeling tests. No network, temp dirs.
|
|
3
|
+
* Includes the honest-boundary grep-asserts (no ollama/llm/fetch, no fabricated
|
|
4
|
+
* keyword literals) required by the s47/s51 acceptance criteria.
|
|
5
|
+
*/
|
|
6
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { openStore } from "../store/sqlite/utils.js";
|
|
13
|
+
import { upsertCheckpoint } from "../store/sqlite/checkpoints.js";
|
|
14
|
+
import { loadEmbeddings, buildTopicModel, DEFAULT_CLUSTER_CONFIG } from "./cluster.js";
|
|
15
|
+
import { tfidfScores, membershipConfidence } from "./labels.js";
|
|
16
|
+
let tmpDir;
|
|
17
|
+
let counter = 0;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mc-topics-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
function stateDir() {
|
|
25
|
+
return join(tmpDir, `run-${counter++}`);
|
|
26
|
+
}
|
|
27
|
+
/** Unit vector along axis `axis` (dim = total dims). */
|
|
28
|
+
function axisVec(axis, dim) {
|
|
29
|
+
const v = new Array(dim).fill(0);
|
|
30
|
+
v[axis] = 1;
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
function cp(id, text, embedding, session = "sess_t") {
|
|
34
|
+
return {
|
|
35
|
+
checkpointId: id,
|
|
36
|
+
sessionId: session,
|
|
37
|
+
summary: text,
|
|
38
|
+
normalizedText: text,
|
|
39
|
+
keyDecisions: [],
|
|
40
|
+
nextSteps: [],
|
|
41
|
+
filesModified: [],
|
|
42
|
+
tokenEstimate: 0,
|
|
43
|
+
regionHash: "r",
|
|
44
|
+
embedding,
|
|
45
|
+
timestamp: 1,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Seed N clusters of M chunks each: cluster i sits near axis i with distinct terms. */
|
|
49
|
+
function seedClusters(dir, clusters, perCluster, dim) {
|
|
50
|
+
openStore(dir);
|
|
51
|
+
const terms = ["sqlite", "wal", "checkpoint", "recovery", "backup", "replay", "fork", "merge"];
|
|
52
|
+
for (let c = 0; c < clusters; c++) {
|
|
53
|
+
for (let m = 0; m < perCluster; m++) {
|
|
54
|
+
// Near-axis vector with slight per-member jitter (stays closest to its axis).
|
|
55
|
+
const v = axisVec(c, dim).map((x, i) => x + (i === c ? 0 : (m + 1) * 1e-4 * (i + 1)));
|
|
56
|
+
const term = terms[(c * 2) % terms.length];
|
|
57
|
+
const term2 = terms[(c * 2 + 1) % terms.length];
|
|
58
|
+
upsertCheckpoint(cp(`chkpt_c${c}_${m}`, `${term} ${term2} work item ${m} details`, v), dir);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
test("loadEmbeddings returns only chunks with embeddings + text", () => {
|
|
63
|
+
const dir = stateDir();
|
|
64
|
+
openStore(dir);
|
|
65
|
+
upsertCheckpoint(cp("a", "hello world", axisVec(0, 4)), dir);
|
|
66
|
+
upsertCheckpoint(cp("b", "", axisVec(1, 4)), dir); // empty text → skipped
|
|
67
|
+
upsertCheckpoint(cp("c", "no embedding here", [], "sess_t"), dir); // empty embedding → skipped
|
|
68
|
+
const chunks = loadEmbeddings(openStore(dir));
|
|
69
|
+
assert.equal(chunks.length, 1);
|
|
70
|
+
assert.equal(chunks[0].chunkId, "a");
|
|
71
|
+
assert.equal(chunks[0].text, "hello world");
|
|
72
|
+
});
|
|
73
|
+
test("buildTopicModel finds 3 well-separated clusters (silhouette picks k=3)", () => {
|
|
74
|
+
const dir = stateDir();
|
|
75
|
+
seedClusters(dir, 3, 6, 8); // 18 chunks, 3 tight clusters
|
|
76
|
+
const model = buildTopicModel(openStore(dir), { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
|
|
77
|
+
assert.equal(model.totalChunks, 18);
|
|
78
|
+
assert.equal(model.k, 3);
|
|
79
|
+
assert.equal(model.topics.length, 3);
|
|
80
|
+
// Every chunk assigned; assignment count == chunk count.
|
|
81
|
+
assert.equal(model.assignments.length, 18);
|
|
82
|
+
// Labels reflect the seeded discriminative terms (sqlite/wal, checkpoint/recovery, backup/replay).
|
|
83
|
+
const labels = model.topics.map((t) => t.label).join(" | ");
|
|
84
|
+
assert.ok(/sqlite|checkpoint|backup/.test(labels), `labels should carry seeded terms: ${labels}`);
|
|
85
|
+
});
|
|
86
|
+
test("corpus too small → single general cluster, no crash", () => {
|
|
87
|
+
const dir = stateDir();
|
|
88
|
+
openStore(dir);
|
|
89
|
+
upsertCheckpoint(cp("only", "lone chunk about sqlite", axisVec(0, 4)), dir);
|
|
90
|
+
const model = buildTopicModel(openStore(dir), DEFAULT_CLUSTER_CONFIG); // kRange[0]=3 > 1 chunk
|
|
91
|
+
assert.equal(model.k, 1);
|
|
92
|
+
assert.equal(model.topics.length, 1);
|
|
93
|
+
assert.equal(model.topics[0].memoryCount, 1);
|
|
94
|
+
assert.equal(model.silhouetteScore, null);
|
|
95
|
+
});
|
|
96
|
+
test("empty corpus → zero topics, no crash", () => {
|
|
97
|
+
const dir = stateDir();
|
|
98
|
+
openStore(dir);
|
|
99
|
+
const model = buildTopicModel(openStore(dir), DEFAULT_CLUSTER_CONFIG);
|
|
100
|
+
assert.equal(model.k, 0);
|
|
101
|
+
assert.equal(model.topics.length, 0);
|
|
102
|
+
assert.equal(model.assignments.length, 0);
|
|
103
|
+
});
|
|
104
|
+
test("tfidfScores surfaces discriminative terms, down-weights common ones", () => {
|
|
105
|
+
const mk = (text) => ({
|
|
106
|
+
chunkId: text, sessionId: "s", vec: [1], text,
|
|
107
|
+
});
|
|
108
|
+
const corpus = [
|
|
109
|
+
mk("sqlite wal sqlite pragma"),
|
|
110
|
+
mk("sqlite wal journal"),
|
|
111
|
+
mk("react component render"),
|
|
112
|
+
mk("react hooks state"),
|
|
113
|
+
];
|
|
114
|
+
const members = corpus.slice(0, 2); // the sqlite cluster
|
|
115
|
+
const scores = tfidfScores(members, corpus);
|
|
116
|
+
const top = scores[0]?.term;
|
|
117
|
+
assert.ok(top === "sqlite" || top === "wal", `top term should be discriminative, got ${top}`);
|
|
118
|
+
// 'react' never appears in members → not scored for this cluster.
|
|
119
|
+
assert.ok(!scores.some((s) => s.term === "react"));
|
|
120
|
+
});
|
|
121
|
+
test("membershipConfidence maps cosine [-1,1] → [0,1]", () => {
|
|
122
|
+
assert.equal(membershipConfidence(1), 1);
|
|
123
|
+
assert.equal(membershipConfidence(-1), 0);
|
|
124
|
+
assert.equal(membershipConfidence(0), 0.5);
|
|
125
|
+
});
|
|
126
|
+
test("buildTopicModel is deterministic for a fixed seed", () => {
|
|
127
|
+
const dir = stateDir();
|
|
128
|
+
seedClusters(dir, 3, 5, 8);
|
|
129
|
+
const db = openStore(dir);
|
|
130
|
+
const a = buildTopicModel(db, { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
|
|
131
|
+
const b = buildTopicModel(db, { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
|
|
132
|
+
assert.equal(a.k, b.k);
|
|
133
|
+
assert.deepEqual(a.topics.map((t) => t.label).sort(), b.topics.map((t) => t.label).sort());
|
|
134
|
+
});
|
|
135
|
+
test("honest boundary: no LLM/Ollama/network/keyword-literals in src/topics", () => {
|
|
136
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
137
|
+
const srcTopics = join(here, "..", "..", "..", "src", "topics"); // dist/src/topics → src/topics
|
|
138
|
+
const files = readdirSync(srcTopics).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
|
|
139
|
+
const banned = [/ollama/i, /\bllm\b/i, /\bfetch\s*\(/, /\bhybrid\b/i];
|
|
140
|
+
const fabricated = ["graphql", "docker", "stacktrace", "bottleneck", "trade-off"];
|
|
141
|
+
for (const f of files) {
|
|
142
|
+
const text = readFileSync(join(srcTopics, f), "utf8");
|
|
143
|
+
for (const re of banned) {
|
|
144
|
+
assert.ok(!re.test(text), `${f} must not contain ${re} (PREVENT-PI-004 / no-LLM boundary)`);
|
|
145
|
+
}
|
|
146
|
+
for (const word of fabricated) {
|
|
147
|
+
assert.ok(!text.toLowerCase().includes(`"${word}"`), `${f} must not hardcode fabricated taxonomy term "${word}"`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — S51 topics barrel. Re-export only; no logic.
|
|
3
|
+
*/
|
|
4
|
+
export { loadEmbeddings, buildTopicModel, DEFAULT_CLUSTER_CONFIG, } from "./cluster.js";
|
|
5
|
+
export { createTopicStore, getWikiCompactCounter, bumpWikiCompactCounter } from "./store.js";
|
|
6
|
+
export { tokenize, tfidfScores, labelFromScores, membershipConfidence } from "./labels.js";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kselection.ts — S51A k-selection criterion math for topic clustering.
|
|
3
|
+
*
|
|
4
|
+
* Pure local math (PREVENT-PI-004): within-cluster sum of squares (cosine
|
|
5
|
+
* distance), mean silhouette score, and the elbow heuristic. k-means++ is
|
|
6
|
+
* reused from src/dedup/raptor/kmeans.ts. No model calls, no network.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from cluster.ts so each module stays under the project's 300-line
|
|
9
|
+
* file-size cap with a single responsibility: this module owns the
|
|
10
|
+
* "which k is best" criterion; cluster.ts owns load + orchestration.
|
|
11
|
+
*
|
|
12
|
+
* Host-agnostic (no pi imports). Parameterized only by Vector arrays.
|
|
13
|
+
*/
|
|
14
|
+
import { cosineDistance, kmeanspp, meanVector, } from "../dedup/raptor/kmeans.js";
|
|
15
|
+
/** Within-cluster sum of squares (cosine distance) for one clustering. */
|
|
16
|
+
export function wcss(points, assignments, centroids) {
|
|
17
|
+
let s = 0;
|
|
18
|
+
for (let i = 0; i < points.length; i++) {
|
|
19
|
+
const d = cosineDistance(points[i], centroids[assignments[i]]);
|
|
20
|
+
s += d * d;
|
|
21
|
+
}
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Mean silhouette score across all points (−1..1); null when undefined
|
|
26
|
+
* (k<=1 or k>=n, or every cluster is a singleton).
|
|
27
|
+
*/
|
|
28
|
+
export function silhouette(points, assignments, k) {
|
|
29
|
+
const n = points.length;
|
|
30
|
+
if (k <= 1 || k >= n)
|
|
31
|
+
return null;
|
|
32
|
+
// Precompute cluster sizes once.
|
|
33
|
+
const sizes = new Map();
|
|
34
|
+
for (const a of assignments)
|
|
35
|
+
sizes.set(a, (sizes.get(a) ?? 0) + 1);
|
|
36
|
+
let sum = 0;
|
|
37
|
+
let counted = 0;
|
|
38
|
+
for (let i = 0; i < n; i++) {
|
|
39
|
+
const a = assignments[i];
|
|
40
|
+
const ownSize = sizes.get(a) ?? 0;
|
|
41
|
+
if (ownSize <= 1)
|
|
42
|
+
continue; // singleton cluster → contribution 0 (skip)
|
|
43
|
+
let aSum = 0;
|
|
44
|
+
const bSum = new Map();
|
|
45
|
+
for (let j = 0; j < n; j++) {
|
|
46
|
+
if (i === j)
|
|
47
|
+
continue;
|
|
48
|
+
const d = cosineDistance(points[i], points[j]);
|
|
49
|
+
if (assignments[j] === a) {
|
|
50
|
+
aSum += d;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
bSum.set(assignments[j], (bSum.get(assignments[j]) ?? 0) + d);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const aMean = aSum / (ownSize - 1); // exclude self
|
|
57
|
+
let b = Infinity;
|
|
58
|
+
for (const [cluster, total] of bSum) {
|
|
59
|
+
const cnt = sizes.get(cluster) ?? 0;
|
|
60
|
+
if (cnt > 0)
|
|
61
|
+
b = Math.min(b, total / cnt);
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isFinite(b))
|
|
64
|
+
continue;
|
|
65
|
+
const denom = Math.max(aMean, b);
|
|
66
|
+
if (denom > 0) {
|
|
67
|
+
sum += (b - aMean) / denom;
|
|
68
|
+
counted++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return counted > 0 ? sum / counted : null;
|
|
72
|
+
}
|
|
73
|
+
/** Elbow: index of max curvature on the WCSS-vs-k curve. */
|
|
74
|
+
export function elbowIndex(ks, wcssVals) {
|
|
75
|
+
if (ks.length <= 2)
|
|
76
|
+
return 0;
|
|
77
|
+
// Normalize and find the point farthest from the line joining the endpoints.
|
|
78
|
+
const x0 = ks[0];
|
|
79
|
+
const y0 = wcssVals[0];
|
|
80
|
+
const x1 = ks[ks.length - 1];
|
|
81
|
+
const y1 = wcssVals[wcssVals.length - 1];
|
|
82
|
+
const dx = x1 - x0;
|
|
83
|
+
const dy = y1 - y0;
|
|
84
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
85
|
+
let best = 0;
|
|
86
|
+
let bestDist = -1;
|
|
87
|
+
for (let i = 0; i < ks.length; i++) {
|
|
88
|
+
const dist = Math.abs(dy * ks[i] - dx * wcssVals[i] + x1 * y0 - y1 * x0) / len;
|
|
89
|
+
if (dist > bestDist) {
|
|
90
|
+
bestDist = dist;
|
|
91
|
+
best = i;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return best;
|
|
95
|
+
}
|
|
96
|
+
/** Run k-means with restarts for one k; keep the lowest-WCSS result. */
|
|
97
|
+
export function bestForK(points, k, cfg) {
|
|
98
|
+
let best = null;
|
|
99
|
+
for (let r = 0; r < cfg.restarts; r++) {
|
|
100
|
+
const res = kmeanspp(points, k, { seed: cfg.seed + r });
|
|
101
|
+
if (res.k === 0)
|
|
102
|
+
continue;
|
|
103
|
+
const w = wcss(points, res.assignments, res.centroids);
|
|
104
|
+
if (!best || w < best.wcss) {
|
|
105
|
+
best = {
|
|
106
|
+
k: res.k,
|
|
107
|
+
assignments: res.assignments,
|
|
108
|
+
centroids: res.centroids,
|
|
109
|
+
wcss: w,
|
|
110
|
+
silhouette: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Degenerate: kmeans collapsed everything to < k clusters.
|
|
115
|
+
if (!best) {
|
|
116
|
+
const mean = meanVector(points);
|
|
117
|
+
best = {
|
|
118
|
+
k: 1,
|
|
119
|
+
assignments: points.map(() => 0),
|
|
120
|
+
centroids: [mean],
|
|
121
|
+
wcss: 0,
|
|
122
|
+
silhouette: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
best.silhouette = silhouette(points, best.assignments, best.k);
|
|
126
|
+
return best;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run k-means across the k search space and pick the best candidate:
|
|
130
|
+
* prefer silhouette when any candidate produced a computable score, else
|
|
131
|
+
* the elbow on the WCSS curve. `points` must be non-empty and `ks` non-empty.
|
|
132
|
+
*/
|
|
133
|
+
export function selectK(points, ks, cfg) {
|
|
134
|
+
const candidates = ks.map((k) => bestForK(points, k, cfg));
|
|
135
|
+
const withSil = candidates.filter((c) => c.silhouette !== null);
|
|
136
|
+
if (withSil.length > 0) {
|
|
137
|
+
const chosen = withSil.reduce((a, b) => (b.silhouette ?? -1) > (a.silhouette ?? -1) ? b : a);
|
|
138
|
+
return { candidate: chosen, criterion: "silhouette" };
|
|
139
|
+
}
|
|
140
|
+
const idx = elbowIndex(candidates.map((c) => c.k), candidates.map((c) => c.wcss));
|
|
141
|
+
return { candidate: candidates[idx], criterion: "elbow" };
|
|
142
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* labels.ts — S51A TF-IDF cluster labeling + membership confidence.
|
|
3
|
+
*
|
|
4
|
+
* Pure local math over real stored text (PREVENT-PI-004). NO fabricated keyword
|
|
5
|
+
* taxonomy, NO model: a cluster's label is the top TF-IDF terms of its real member
|
|
6
|
+
* chunks (real TF × real IDF over the whole corpus), so common words are
|
|
7
|
+
* naturally down-weighted by IDF rather than by a hand-written stopword list.
|
|
8
|
+
*/
|
|
9
|
+
/** Tokenize text: lowercase, split on non-alphanumeric, drop empties/1-char. */
|
|
10
|
+
export function tokenize(text) {
|
|
11
|
+
return text
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.split(/[^a-z0-9]+/)
|
|
14
|
+
.filter((t) => t.length > 1);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Compute TF-IDF term scores for one cluster's member chunks against the whole
|
|
18
|
+
* corpus. TF = term count across the cluster's members; IDF = log(1 + N/df)
|
|
19
|
+
* where N = total chunks and df = chunks containing the term. Returns the full
|
|
20
|
+
* list sorted by score desc.
|
|
21
|
+
*/
|
|
22
|
+
export function tfidfScores(memberChunks, corpus) {
|
|
23
|
+
const tf = new Map();
|
|
24
|
+
for (const c of memberChunks) {
|
|
25
|
+
for (const term of tokenize(c.text))
|
|
26
|
+
tf.set(term, (tf.get(term) ?? 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
if (tf.size === 0)
|
|
29
|
+
return [];
|
|
30
|
+
// Document frequency over the whole corpus (one vote per chunk per term).
|
|
31
|
+
const df = new Map();
|
|
32
|
+
for (const c of corpus) {
|
|
33
|
+
for (const term of new Set(tokenize(c.text)))
|
|
34
|
+
df.set(term, (df.get(term) ?? 0) + 1);
|
|
35
|
+
}
|
|
36
|
+
const n = Math.max(1, corpus.length);
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const [term, f] of tf) {
|
|
39
|
+
const idf = Math.log(1 + n / (df.get(term) ?? 1));
|
|
40
|
+
out.push({ term, score: f * idf });
|
|
41
|
+
}
|
|
42
|
+
out.sort((a, b) => b.score - a.score || a.term.localeCompare(b.term));
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** Join the top `topTerms` TF-IDF terms into a human label. */
|
|
46
|
+
export function labelFromScores(scores, topTerms) {
|
|
47
|
+
const top = scores.slice(0, Math.max(1, topTerms)).map((s) => s.term);
|
|
48
|
+
return top.length > 0 ? top.join(" · ") : "general";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Normalized cosine membership confidence in [0,1]. Cosine is in [-1,1]; map to
|
|
52
|
+
* [0,1] via (1 + cos)/2. A chunk sitting exactly on its centroid → 1.
|
|
53
|
+
*/
|
|
54
|
+
export function membershipConfidence(cosineSimilarity) {
|
|
55
|
+
return Math.max(0, Math.min(1, (1 + cosineSimilarity) / 2));
|
|
56
|
+
}
|