Skip to content

Commit b14b322

Browse files
committed
feat(theme-manager): implement connected component clustering for theme split
Implement xMemory paper algorithm for theme splitting: - Add semanticEmbCache to cache semantic embeddings during assimilate - clusterSemantics(): Build similarity graph and find connected components using DFS-based algorithm with threshold 0.66 - computeClusterCentroid(): Helper to compute cluster centroid from cached embeddings - Fallback to binary split if connected component still exceeds maxThemeSize This ensures related semantics stay together when themes are split, improving topic coherence compared to the previous simple midpoint split.
1 parent 1970a78 commit b14b322

1 file changed

Lines changed: 142 additions & 26 deletions

File tree

cli/src/lib/memory/theme-manager.ts

Lines changed: 142 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,14 @@ export class ThemeManager {
6363
private userId: string;
6464
private config: ThemeConfig;
6565
private fs: FileSystem;
66-
66+
6767
private themes: Map<string, ThemeNode> = new Map();
68-
private embeddings: Map<string, number[]> | null = null; // Lazy loaded
68+
private embeddings: Map<string, number[]> | null = null; // Theme centroids (lazy loaded)
6969
private embeddingsLoaded = false;
70-
70+
71+
// Semantic embeddings cache (populated during assimilate, used for clustering)
72+
private semanticEmbCache: Map<string, number[]> = new Map();
73+
7174
constructor(
7275
storageDir: string,
7376
userId: string,
@@ -172,6 +175,11 @@ export class ThemeManager {
172175
this.loadEmbeddings();
173176
}
174177

178+
// Cache semantic embeddings for clustering during split
179+
for (const sem of newSemantics) {
180+
this.semanticEmbCache.set(sem.memoryId, sem.embedding);
181+
}
182+
175183
for (const sem of newSemantics) {
176184
this.attachSemantic(sem);
177185
}
@@ -239,33 +247,141 @@ export class ThemeManager {
239247
}
240248

241249
for (const theme of toSplit) {
242-
// Simple split: divide into two themes
243-
const mid = Math.ceil(theme.semanticIds.length / 2);
244-
const ids1 = theme.semanticIds.slice(0, mid);
245-
const ids2 = theme.semanticIds.slice(mid);
246-
247-
// Update original theme
248-
theme.semanticIds = ids1;
249-
theme.memberCount = ids1.length;
250-
theme.summary = theme.summary + ' (split 1)';
250+
// Use connected component clustering (xMemory paper algorithm)
251+
const clusters = this.clusterSemantics(theme.semanticIds);
252+
253+
if (clusters.length <= 1) {
254+
// Cannot split further, keep as is
255+
continue;
256+
}
257+
258+
// Update original theme with first cluster
259+
const firstCluster = clusters[0];
260+
theme.semanticIds = firstCluster;
261+
theme.memberCount = firstCluster.length;
262+
theme.centroid = this.computeClusterCentroid(firstCluster);
251263
theme.updatedAt = new Date();
264+
this.embeddings!.set(theme.themeId, theme.centroid);
265+
266+
// Create new themes for remaining clusters
267+
for (let i = 1; i < clusters.length; i++) {
268+
const cluster = clusters[i];
269+
const newThemeId = randomUUID();
270+
const centroid = this.computeClusterCentroid(cluster);
271+
272+
const newTheme: ThemeNode = {
273+
themeId: newThemeId,
274+
summary: `${theme.summary} (cluster ${i + 1})`,
275+
centroid,
276+
semanticIds: cluster,
277+
neighbors: [],
278+
memberCount: cluster.length,
279+
createdAt: new Date(),
280+
updatedAt: new Date(),
281+
};
282+
283+
this.themes.set(newThemeId, newTheme);
284+
this.embeddings!.set(newThemeId, centroid);
285+
}
286+
}
287+
}
252288

253-
// Create new theme for second half
254-
const newThemeId = randomUUID();
255-
const newTheme: ThemeNode = {
256-
themeId: newThemeId,
257-
summary: theme.summary.replace('(split 1)', '(split 2)'),
258-
centroid: theme.centroid, // Will need proper recomputation
259-
semanticIds: ids2,
260-
neighbors: [],
261-
memberCount: ids2.length,
262-
createdAt: new Date(),
263-
updatedAt: new Date(),
264-
};
289+
/**
290+
* Cluster semantics by connectivity (xMemory paper algorithm)
291+
* Builds similarity graph and finds connected components
292+
*/
293+
private clusterSemantics(semanticIds: string[]): string[][] {
294+
const CLUSTER_THRESHOLD = 0.66; // Similarity threshold for edge
295+
const n = semanticIds.length;
265296

266-
this.themes.set(newThemeId, newTheme);
267-
this.embeddings!.set(newThemeId, newTheme.centroid);
297+
if (n <= this.config.maxThemeSize) {
298+
return [semanticIds];
268299
}
300+
301+
// Get embeddings for all semantics
302+
const embeddings: number[][] = [];
303+
for (const id of semanticIds) {
304+
const emb = this.semanticEmbCache.get(id);
305+
if (emb) {
306+
embeddings.push(emb);
307+
} else {
308+
// Fallback: use empty vector (will not connect)
309+
embeddings.push([]);
310+
}
311+
}
312+
313+
// Build adjacency matrix based on similarity
314+
const adj: boolean[][] = Array.from({ length: n }, () =>
315+
Array(n).fill(false)
316+
);
317+
318+
for (let i = 0; i < n; i++) {
319+
for (let j = i + 1; j < n; j++) {
320+
if (embeddings[i].length > 0 && embeddings[j].length > 0) {
321+
const sim = cosineSim(embeddings[i], embeddings[j]);
322+
if (sim >= CLUSTER_THRESHOLD) {
323+
adj[i][j] = adj[j][i] = true;
324+
}
325+
}
326+
}
327+
}
328+
329+
// Find connected components using DFS
330+
const visited = new Set<number>();
331+
const clusters: string[][] = [];
332+
333+
for (let i = 0; i < n; i++) {
334+
if (visited.has(i)) continue;
335+
336+
const component: number[] = [];
337+
const stack = [i];
338+
339+
while (stack.length > 0) {
340+
const node = stack.pop()!;
341+
if (visited.has(node)) continue;
342+
visited.add(node);
343+
component.push(node);
344+
345+
for (let j = 0; j < n; j++) {
346+
if (adj[node][j] && !visited.has(j)) {
347+
stack.push(j);
348+
}
349+
}
350+
}
351+
352+
clusters.push(component.map(idx => semanticIds[idx]));
353+
}
354+
355+
// Fallback: if any cluster is still too large, force binary split
356+
const result: string[][] = [];
357+
for (const cluster of clusters) {
358+
if (cluster.length > this.config.maxThemeSize) {
359+
// Force split into two halves
360+
const mid = Math.ceil(cluster.length / 2);
361+
result.push(cluster.slice(0, mid));
362+
result.push(cluster.slice(mid));
363+
} else {
364+
result.push(cluster);
365+
}
366+
}
367+
368+
return result;
369+
}
370+
371+
/**
372+
* Compute centroid for a cluster of semantic IDs
373+
*/
374+
private computeClusterCentroid(semanticIds: string[]): number[] {
375+
const embeddings: number[][] = [];
376+
377+
for (const id of semanticIds) {
378+
const emb = this.semanticEmbCache.get(id);
379+
if (emb && emb.length > 0) {
380+
embeddings.push(emb);
381+
}
382+
}
383+
384+
return computeCentroid(embeddings);
269385
}
270386

271387
private mergeSmallThemes(): void {

0 commit comments

Comments
 (0)