@@ -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