package context import ( "math" "testing" "time" "github.com/syntrex/gomcp/internal/domain/memory" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- RelevanceScorer tests --- func TestNewRelevanceScorer(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) require.NotNil(t, scorer) assert.Equal(t, cfg.RecencyWeight, scorer.config.RecencyWeight) } func TestRelevanceScorer_ScoreKeywordMatch(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) fact := memory.NewFact("Architecture uses clean layers with dependency injection", memory.LevelProject, "arch", "") // Keywords that match score1 := scorer.scoreKeywordMatch(fact, []string{"architecture", "clean", "layers"}) assert.Greater(t, score1, 0.0) // Keywords that don't match score2 := scorer.scoreKeywordMatch(fact, []string{"database", "migration", "schema"}) assert.Equal(t, 0.0, score2) // Partial match scores less than full match score3 := scorer.scoreKeywordMatch(fact, []string{"architecture", "unrelated"}) assert.Greater(t, score3, 0.0) assert.Less(t, score3, score1) } func TestRelevanceScorer_ScoreKeywordMatch_EmptyKeywords(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) fact := memory.NewFact("test content", memory.LevelProject, "test", "") score := scorer.scoreKeywordMatch(fact, nil) assert.Equal(t, 0.0, score) score2 := scorer.scoreKeywordMatch(fact, []string{}) assert.Equal(t, 0.0, score2) } func TestRelevanceScorer_ScoreRecency(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) // Recent fact should score high recentFact := memory.NewFact("recent", memory.LevelProject, "test", "") recentFact.CreatedAt = time.Now().Add(-1 * time.Hour) scoreRecent := scorer.scoreRecency(recentFact) assert.Greater(t, scoreRecent, 0.5) // Old fact should score lower oldFact := memory.NewFact("old", memory.LevelProject, "test", "") oldFact.CreatedAt = time.Now().Add(-30 * 24 * time.Hour) // 30 days ago scoreOld := scorer.scoreRecency(oldFact) assert.Less(t, scoreOld, scoreRecent) // Very old fact should score very low veryOldFact := memory.NewFact("ancient", memory.LevelProject, "test", "") veryOldFact.CreatedAt = time.Now().Add(-365 * 24 * time.Hour) scoreVeryOld := scorer.scoreRecency(veryOldFact) assert.Less(t, scoreVeryOld, scoreOld) } func TestRelevanceScorer_ScoreLevel(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) tests := []struct { level memory.HierLevel minScore float64 }{ {memory.LevelProject, 0.9}, // L0 scores highest {memory.LevelDomain, 0.6}, // L1 {memory.LevelModule, 0.3}, // L2 {memory.LevelSnippet, 0.1}, // L3 scores lowest } var prevScore float64 = 2.0 for _, tt := range tests { fact := memory.NewFact("test", tt.level, "test", "") score := scorer.scoreLevel(fact) assert.Greater(t, score, 0.0, "level %d should have positive score", tt.level) assert.Less(t, score, prevScore, "level %d should score less than level %d", tt.level, tt.level-1) prevScore = score } } func TestRelevanceScorer_ScoreFrequency(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) // Zero access count score0 := scorer.scoreFrequency(0) assert.Equal(t, 0.0, score0) // Some accesses score5 := scorer.scoreFrequency(5) assert.Greater(t, score5, 0.0) // More accesses = higher score (but with diminishing returns) score50 := scorer.scoreFrequency(50) assert.Greater(t, score50, score5) // Score is bounded (shouldn't exceed 1.0) score1000 := scorer.scoreFrequency(1000) assert.LessOrEqual(t, score1000, 1.0) } func TestRelevanceScorer_ScoreFact(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) fact := memory.NewFact("Architecture uses clean layers", memory.LevelProject, "arch", "") keywords := []string{"architecture", "clean"} score := scorer.ScoreFact(fact, keywords, 3) assert.Greater(t, score, 0.0) assert.LessOrEqual(t, score, 1.0) } func TestRelevanceScorer_ScoreFact_StaleFact(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) fact := memory.NewFact("stale info", memory.LevelProject, "arch", "") fact.MarkStale() staleFact := scorer.ScoreFact(fact, []string{"info"}, 0) freshFact := memory.NewFact("fresh info", memory.LevelProject, "arch", "") freshScore := scorer.ScoreFact(freshFact, []string{"info"}, 0) // Stale facts should be penalized assert.Less(t, staleFact, freshScore) } func TestRelevanceScorer_ScoreFact_ArchivedFact(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) fact := memory.NewFact("archived info", memory.LevelProject, "arch", "") fact.Archive() score := scorer.ScoreFact(fact, []string{"info"}, 0) assert.Equal(t, 0.0, score, "archived facts should score 0") } func TestRelevanceScorer_RankFacts(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) facts := []*memory.Fact{ memory.NewFact("Low relevance snippet", memory.LevelSnippet, "misc", ""), memory.NewFact("Architecture uses clean dependency injection", memory.LevelProject, "arch", ""), memory.NewFact("Domain boundary for auth module", memory.LevelDomain, "auth", ""), } keywords := []string{"architecture", "clean"} accessCounts := map[string]int{ facts[1].ID: 10, // architecture fact accessed often } ranked := scorer.RankFacts(facts, keywords, accessCounts) require.Len(t, ranked, 3) // Architecture fact should rank highest (L0 + keyword match + access count) assert.Equal(t, facts[1].ID, ranked[0].Fact.ID) // Scores should be descending for i := 1; i < len(ranked); i++ { assert.GreaterOrEqual(t, ranked[i-1].Score, ranked[i].Score, "facts should be sorted by score descending") } } func TestRelevanceScorer_RankFacts_Empty(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) ranked := scorer.RankFacts(nil, []string{"test"}, nil) assert.Empty(t, ranked) } func TestRelevanceScorer_RankFacts_FiltersArchived(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) active := memory.NewFact("active fact", memory.LevelProject, "arch", "") archived := memory.NewFact("archived fact", memory.LevelProject, "arch", "") archived.Archive() ranked := scorer.RankFacts([]*memory.Fact{active, archived}, []string{"fact"}, nil) require.Len(t, ranked, 1) assert.Equal(t, active.ID, ranked[0].Fact.ID) } func TestRelevanceScorer_DecayFunction(t *testing.T) { cfg := DefaultEngineConfig() cfg.DecayHalfLifeHours = 24.0 // 1 day half-life scorer := NewRelevanceScorer(cfg) // At t=0, decay should be 1.0 decay0 := scorer.decayFactor(0) assert.InDelta(t, 1.0, decay0, 0.01) // At t=half-life, decay should be ~0.5 decayHalf := scorer.decayFactor(24.0) assert.InDelta(t, 0.5, decayHalf, 0.05) // At t=2*half-life, decay should be ~0.25 decayDouble := scorer.decayFactor(48.0) assert.InDelta(t, 0.25, decayDouble, 0.05) } func TestRelevanceScorer_DomainMatch(t *testing.T) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) factArch := memory.NewFact("architecture pattern", memory.LevelDomain, "architecture", "") factAuth := memory.NewFact("auth module pattern", memory.LevelDomain, "auth", "") // Keywords mentioning "architecture" should boost the arch fact keywords := []string{"architecture", "pattern"} scoreArch := scorer.ScoreFact(factArch, keywords, 0) scoreAuth := scorer.ScoreFact(factAuth, keywords, 0) // Both match "pattern" but only arch fact matches "architecture" in content+domain assert.Greater(t, scoreArch, scoreAuth) } // --- Benchmark --- func BenchmarkScoreFact(b *testing.B) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) fact := memory.NewFact("Architecture uses clean layers with dependency injection", memory.LevelProject, "arch", "core") keywords := []string{"architecture", "clean", "layers", "dependency"} b.ResetTimer() for i := 0; i < b.N; i++ { scorer.ScoreFact(fact, keywords, 5) } } func BenchmarkRankFacts(b *testing.B) { cfg := DefaultEngineConfig() scorer := NewRelevanceScorer(cfg) facts := make([]*memory.Fact, 100) for i := 0; i < 100; i++ { facts[i] = memory.NewFact( "fact content with various keywords for testing relevance scoring", memory.HierLevel(i%4), "domain", "module", ) } keywords := []string{"content", "testing", "scoring"} _ = math.Abs(0) // use math import b.ResetTimer() for i := 0; i < b.N; i++ { scorer.RankFacts(facts, keywords, nil) } }