@@ -52,6 +52,25 @@ describe('cosineSimilarity', () => {
5252 it ( 'should handle negative values' , ( ) => {
5353 expect ( cosineSimilarity ( [ 1 , 0 ] , [ - 1 , 0 ] ) ) . toBeCloseTo ( - 1.0 ) ;
5454 } ) ;
55+
56+ it ( 'should handle mismatched dimensions without NaN' , ( ) => {
57+ const short = [ 1 , 0 ] ;
58+ const long = [ 1 , 0 , 0.5 , 0.3 ] ;
59+ const sim = cosineSimilarity ( long , short ) ;
60+ expect ( Number . isNaN ( sim ) ) . toBe ( false ) ;
61+ expect ( sim ) . toBeGreaterThan ( 0 ) ;
62+ // Symmetric
63+ expect ( cosineSimilarity ( short , long ) ) . toBeCloseTo ( sim ) ;
64+ } ) ;
65+
66+ it ( 'should return 0 for mismatched zero-overlap vectors' , ( ) => {
67+ // short has values only in dims 0-1, long only in dims 2-3
68+ const a = [ 1 , 0 ] ;
69+ const b = [ 0 , 0 , 1 , 0 ] ;
70+ const sim = cosineSimilarity ( a , b ) ;
71+ expect ( Number . isNaN ( sim ) ) . toBe ( false ) ;
72+ expect ( sim ) . toBeCloseTo ( 0.0 ) ;
73+ } ) ;
5574} ) ;
5675
5776// -- Forest --
@@ -281,6 +300,27 @@ describe('Forest', () => {
281300 expect ( roots ) . toContain ( root ) ;
282301 } ) ;
283302
303+ it ( 'should handle centroid merging with mismatched embedding dimensions' , ( ) => {
304+ const embedder : Embedder = {
305+ embed ( text : string ) : number [ ] {
306+ // Simulate growing vocab: earlier messages have shorter embeddings
307+ if ( text === 'early' ) return [ 1 , 0 ] ;
308+ return [ 0.5 , 0.5 , 0.3 ] ; // later messages have longer embeddings
309+ } ,
310+ } ;
311+ const forest = new Forest ( embedder , stubSummarizer ) ;
312+ forest . insert ( 0 , 'early' ) ;
313+ forest . insert ( 1 , 'later' ) ;
314+ forest . union ( 0 , 1 ) ;
315+
316+ const root = forest . find ( 0 ) ;
317+ const centroid = forest . getCentroid ( root ) ;
318+ expect ( centroid ) . toBeDefined ( ) ;
319+ expect ( centroid ! . every ( ( v ) => ! Number . isNaN ( v ) ) ) . toBe ( true ) ;
320+ // Merged centroid should have max dimension length
321+ expect ( centroid ! . length ) . toBe ( 3 ) ;
322+ } ) ;
323+
284324 it ( 'should return no-op for union of same cluster' , ( ) => {
285325 const forest = new Forest ( stubEmbedder , stubSummarizer ) ;
286326 forest . insert ( 0 , 'a' ) ;
@@ -400,6 +440,72 @@ describe('Forest', () => {
400440 expect ( members ) . toContain ( 1 ) ;
401441 } ) ;
402442
443+ it ( 'should not drop dirty state added by union during resolveDirty' , async ( ) => {
444+ const slowSummarizer : Summarizer = {
445+ async summarize ( messages : string [ ] ) : Promise < string > {
446+ // Simulate slow LLM call
447+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
448+ return messages . join ( '; ' ) ;
449+ } ,
450+ } ;
451+ const forest = new Forest ( stubEmbedder , slowSummarizer ) ;
452+ forest . insert ( 0 , 'a' ) ;
453+ forest . insert ( 1 , 'b' ) ;
454+ forest . union ( 0 , 1 ) ; // dirty cluster {0,1}
455+
456+ // Start resolving — the await inside gives us a window
457+ const resolvePromise = forest . resolveDirty ( ) ;
458+
459+ // While resolve is in flight, add new dirty state
460+ forest . insert ( 2 , 'c' ) ;
461+ forest . insert ( 3 , 'd' ) ;
462+ forest . union ( 2 , 3 ) ; // new dirty cluster {2,3}
463+
464+ await resolvePromise ;
465+
466+ // The new dirty cluster should NOT have been wiped
467+ expect ( forest . isDirty ( forest . find ( 2 ) ) ) . toBe ( true ) ;
468+
469+ // Resolve it now
470+ await forest . resolveDirty ( ) ;
471+ expect ( forest . isDirty ( forest . find ( 2 ) ) ) . toBe ( false ) ;
472+ } ) ;
473+
474+ it ( 'should not overwrite merged cluster dirty state when in-flight root is merged' , async ( ) => {
475+ const slowSummarizer : Summarizer = {
476+ async summarize ( messages : string [ ] ) : Promise < string > {
477+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
478+ return messages . join ( '; ' ) ;
479+ } ,
480+ } ;
481+ const forest = new Forest ( stubEmbedder , slowSummarizer ) ;
482+ forest . insert ( 0 , 'a' ) ;
483+ forest . insert ( 1 , 'b' ) ;
484+ forest . union ( 0 , 1 ) ; // dirty cluster {0,1}
485+ const originalRoot = forest . find ( 0 ) ;
486+
487+ // Start resolving {0,1}
488+ const resolvePromise = forest . resolveDirty ( ) ;
489+
490+ // While {0,1} is being summarized, merge it into a new cluster
491+ forest . insert ( 2 , 'c' ) ;
492+ forest . union ( originalRoot , 2 ) ; // now {0,1,2} is dirty with combined inputs
493+
494+ await resolvePromise ;
495+
496+ // The merged cluster should still be dirty — the stale summary
497+ // from the in-flight call should NOT have resolved it
498+ const mergedRoot = forest . find ( 0 ) ;
499+ expect ( forest . isDirty ( mergedRoot ) ) . toBe ( true ) ;
500+
501+ // Resolve it properly now
502+ await forest . resolveDirty ( ) ;
503+ expect ( forest . isDirty ( forest . find ( 0 ) ) ) . toBe ( false ) ;
504+ // Summary should include all three messages
505+ const summary = forest . summary ( forest . find ( 0 ) ) ! ;
506+ expect ( summary ) . toBeDefined ( ) ;
507+ } ) ;
508+
403509 it ( 'should list all roots' , ( ) => {
404510 const forest = new Forest ( stubEmbedder , stubSummarizer ) ;
405511 forest . insert ( 0 , 'a' ) ;
@@ -688,6 +794,40 @@ describe('ContextWindow', () => {
688794 expect ( cw . totalMessages ) . toBe ( 2 ) ;
689795 } ) ;
690796
797+ it ( 'render(query) should not mutate the embedder corpus' , ( ) => {
798+ const embedder = {
799+ embed ( text : string ) : number [ ] {
800+ if ( text . includes ( 'cat' ) ) return [ 1 , 0 , 0 ] ;
801+ return [ 0 , 0 , 1 ] ;
802+ } ,
803+ embedQuery : vi . fn ( ) . mockReturnValue ( [ 1 , 0 , 0 ] ) ,
804+ } ;
805+
806+ const cw = new ContextWindow ( embedder , stubSummarizer , {
807+ graduateAt : 2 ,
808+ evictAt : 4 ,
809+ maxColdClusters : 10 ,
810+ mergeThreshold : 0.0 ,
811+ } ) ;
812+
813+ cw . append ( 'cat info' ) ;
814+ cw . append ( 'dog info' ) ;
815+ cw . append ( 'hot1' ) ;
816+
817+ // render with query should call embedQuery, not embed
818+ cw . render ( 'cat question' , 1 , 0.0 ) ;
819+ expect ( embedder . embedQuery ) . toHaveBeenCalledWith ( 'cat question' ) ;
820+ } ) ;
821+
822+ it ( 'should throw if evictAt < graduateAt' , ( ) => {
823+ expect ( ( ) => {
824+ new ContextWindow ( stubEmbedder , stubSummarizer , {
825+ graduateAt : 5 ,
826+ evictAt : 3 ,
827+ } ) ;
828+ } ) . toThrow ( 'evictAt (3) must be >= graduateAt (5)' ) ;
829+ } ) ;
830+
691831 it ( 'should expose forest for direct access' , ( ) => {
692832 const cw = new ContextWindow ( stubEmbedder , stubSummarizer ) ;
693833 expect ( cw . forest ) . toBeInstanceOf ( Forest ) ;
0 commit comments