mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Release dec 2025 (#15)
* Resolves issue #13 - Switched model to bge-small-en-v1.5 - Added lazy re-embedding - Added model version tracking per vector - Added conversion of vectors to the new model * Add lfs support to the workflow. * Implements importance scoring with decay + voting #6 * Resolves issue #5 by marking observations as superseeded and scheduled for deletion * Implement pattern detection #7 * Improve injections and observations accuracy - Session start: Recent observations for project context (recency-based) - User prompt: Semantically relevant observations (similarity-based with threshold) * Added two stage retrieval with bi and cross encoder #8 * Implement query expansion and reformulation #9 * Knowledge graph and relationships ( resolves #4 ) - File Overlap Detection: Detects relationships when observations modify/read the same files - Concept Overlap Detection: Detects relationships based on shared semantic concepts - Type Progression Detection: Infers relationships from natural observation type progressions (e.g., discovery → bugfix = "fixes") - Temporal Proximity Detection: Detects relationships between observations in the same session within 5 minutes - Narrative Mention Detection: Detects explicit relationship language in narratives (e.g., "fixes", "depends on", "supersedes") * Add visualisation of the relations to the dashboard. * fixup! Add visualisation of the relations to the dashboard. * Update documentation with new settings and screenshots.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// Package scoring provides importance score calculation for observations.
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// Calculator computes importance scores for observations.
|
||||
type Calculator struct {
|
||||
config *models.ScoringConfig
|
||||
}
|
||||
|
||||
// NewCalculator creates a new scoring calculator.
|
||||
// If config is nil, uses the default configuration.
|
||||
func NewCalculator(config *models.ScoringConfig) *Calculator {
|
||||
if config == nil {
|
||||
config = models.DefaultScoringConfig()
|
||||
}
|
||||
return &Calculator{config: config}
|
||||
}
|
||||
|
||||
// Calculate computes the importance score for an observation at the given time.
|
||||
//
|
||||
// The scoring formula:
|
||||
//
|
||||
// FinalScore = (BaseScore × TypeWeight × RecencyDecay) + FeedbackContrib + ConceptContrib + RetrievalContrib
|
||||
//
|
||||
// Where:
|
||||
// - BaseScore = 1.0
|
||||
// - TypeWeight = observation type multiplier (e.g., bugfix=1.3, change=0.9)
|
||||
// - RecencyDecay = 0.5^(age_days / half_life_days) - halves every 7 days by default
|
||||
// - FeedbackContrib = user_feedback × feedback_weight
|
||||
// - ConceptContrib = sum(concept_weights) × concept_weight_factor
|
||||
// - RetrievalContrib = log2(retrieval_count + 1) × 0.1 × retrieval_weight
|
||||
func (c *Calculator) Calculate(obs *models.Observation, now time.Time) float64 {
|
||||
// 1. Get base type weight
|
||||
typeWeight := models.TypeBaseScore(obs.Type)
|
||||
|
||||
// 2. Calculate recency decay: 0.5^(age_days / half_life_days)
|
||||
ageDays := now.Sub(time.UnixMilli(obs.CreatedAtEpoch)).Hours() / 24.0
|
||||
if ageDays < 0 {
|
||||
ageDays = 0 // Handle future timestamps gracefully
|
||||
}
|
||||
recencyDecay := math.Pow(0.5, ageDays/c.config.RecencyHalfLifeDays)
|
||||
|
||||
// Core score = 1.0 × type_weight × recency_decay
|
||||
coreScore := 1.0 * typeWeight * recencyDecay
|
||||
|
||||
// 3. User feedback contribution: feedback × weight
|
||||
feedbackContrib := float64(obs.UserFeedback) * c.config.FeedbackWeight
|
||||
|
||||
// 4. Concept boost contribution: sum of matching concept weights × factor
|
||||
conceptBoost := 0.0
|
||||
for _, concept := range obs.Concepts {
|
||||
if weight, ok := c.config.ConceptWeights[concept]; ok {
|
||||
conceptBoost += weight
|
||||
}
|
||||
}
|
||||
conceptContrib := conceptBoost * c.config.ConceptWeight
|
||||
|
||||
// 5. Retrieval boost: log2(count + 1) × 0.1 × weight (diminishing returns)
|
||||
retrievalContrib := 0.0
|
||||
if obs.RetrievalCount > 0 {
|
||||
// log2(count + 1) gives diminishing returns: 1→1, 3→2, 7→3, 15→4, etc.
|
||||
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * 0.1
|
||||
retrievalContrib = retrievalBoost * c.config.RetrievalWeight
|
||||
}
|
||||
|
||||
// Final score with minimum threshold
|
||||
finalScore := coreScore + feedbackContrib + conceptContrib + retrievalContrib
|
||||
if finalScore < c.config.MinScore {
|
||||
finalScore = c.config.MinScore
|
||||
}
|
||||
|
||||
return finalScore
|
||||
}
|
||||
|
||||
// CalculateComponents returns the individual components of the importance score.
|
||||
// Useful for debugging and explaining scores to users.
|
||||
func (c *Calculator) CalculateComponents(obs *models.Observation, now time.Time) ScoreComponents {
|
||||
typeWeight := models.TypeBaseScore(obs.Type)
|
||||
|
||||
ageDays := now.Sub(time.UnixMilli(obs.CreatedAtEpoch)).Hours() / 24.0
|
||||
if ageDays < 0 {
|
||||
ageDays = 0
|
||||
}
|
||||
recencyDecay := math.Pow(0.5, ageDays/c.config.RecencyHalfLifeDays)
|
||||
|
||||
coreScore := 1.0 * typeWeight * recencyDecay
|
||||
feedbackContrib := float64(obs.UserFeedback) * c.config.FeedbackWeight
|
||||
|
||||
conceptBoost := 0.0
|
||||
for _, concept := range obs.Concepts {
|
||||
if weight, ok := c.config.ConceptWeights[concept]; ok {
|
||||
conceptBoost += weight
|
||||
}
|
||||
}
|
||||
conceptContrib := conceptBoost * c.config.ConceptWeight
|
||||
|
||||
retrievalContrib := 0.0
|
||||
if obs.RetrievalCount > 0 {
|
||||
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * 0.1
|
||||
retrievalContrib = retrievalBoost * c.config.RetrievalWeight
|
||||
}
|
||||
|
||||
finalScore := coreScore + feedbackContrib + conceptContrib + retrievalContrib
|
||||
if finalScore < c.config.MinScore {
|
||||
finalScore = c.config.MinScore
|
||||
}
|
||||
|
||||
return ScoreComponents{
|
||||
TypeWeight: typeWeight,
|
||||
RecencyDecay: recencyDecay,
|
||||
CoreScore: coreScore,
|
||||
FeedbackContrib: feedbackContrib,
|
||||
ConceptContrib: conceptContrib,
|
||||
RetrievalContrib: retrievalContrib,
|
||||
FinalScore: finalScore,
|
||||
AgeDays: ageDays,
|
||||
}
|
||||
}
|
||||
|
||||
// ScoreComponents contains the breakdown of an importance score calculation.
|
||||
type ScoreComponents struct {
|
||||
TypeWeight float64 `json:"type_weight"`
|
||||
RecencyDecay float64 `json:"recency_decay"`
|
||||
CoreScore float64 `json:"core_score"`
|
||||
FeedbackContrib float64 `json:"feedback_contrib"`
|
||||
ConceptContrib float64 `json:"concept_contrib"`
|
||||
RetrievalContrib float64 `json:"retrieval_contrib"`
|
||||
FinalScore float64 `json:"final_score"`
|
||||
AgeDays float64 `json:"age_days"`
|
||||
}
|
||||
|
||||
// BatchCalculate computes scores for multiple observations.
|
||||
// Returns a map of observation ID to calculated score.
|
||||
func (c *Calculator) BatchCalculate(observations []*models.Observation, now time.Time) map[int64]float64 {
|
||||
scores := make(map[int64]float64, len(observations))
|
||||
for _, obs := range observations {
|
||||
scores[obs.ID] = c.Calculate(obs, now)
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
// RecalculateThreshold returns the minimum duration before an observation
|
||||
// should have its score recalculated. This prevents excessive recalculation
|
||||
// while ensuring scores stay reasonably fresh.
|
||||
func (c *Calculator) RecalculateThreshold() time.Duration {
|
||||
// Recalculate at most every 6 hours
|
||||
// This balances freshness with performance
|
||||
return 6 * time.Hour
|
||||
}
|
||||
|
||||
// UpdateConfig updates the calculator's scoring configuration.
|
||||
// This allows runtime tuning of scoring parameters.
|
||||
func (c *Calculator) UpdateConfig(config *models.ScoringConfig) {
|
||||
if config != nil {
|
||||
c.config = config
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig returns the current scoring configuration.
|
||||
func (c *Calculator) GetConfig() *models.ScoringConfig {
|
||||
return c.config
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
// Package scoring provides importance score calculation for observations.
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// CalculatorSuite is a test suite for the Calculator.
|
||||
type CalculatorSuite struct {
|
||||
suite.Suite
|
||||
calc *Calculator
|
||||
config *models.ScoringConfig
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) SetupTest() {
|
||||
s.config = models.DefaultScoringConfig()
|
||||
s.calc = NewCalculator(s.config)
|
||||
s.now = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func TestCalculatorSuite(t *testing.T) {
|
||||
suite.Run(t, new(CalculatorSuite))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GOOD SCENARIOS - Expected normal operations
|
||||
// =============================================================================
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_NewObservation() {
|
||||
// A brand new observation should have score close to type weight
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Expected: 1.0 × 1.3 (bugfix weight) × 1.0 (no decay) = 1.3
|
||||
s.InDelta(1.3, score, 0.01, "new bugfix should score ~1.3")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_OneWeekOld() {
|
||||
// One week old observation should have half the recency score
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAtEpoch: s.now.Add(-7 * 24 * time.Hour).UnixMilli(),
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Expected: 1.0 × 1.1 (discovery) × 0.5 (7 days half-life) = 0.55
|
||||
s.InDelta(0.55, score, 0.05, "7-day old discovery should score ~0.55")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_TwoWeeksOld() {
|
||||
// Two weeks old should have 1/4 recency score
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeFeature,
|
||||
CreatedAtEpoch: s.now.Add(-14 * 24 * time.Hour).UnixMilli(),
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Expected: 1.0 × 1.2 (feature) × 0.25 (14 days = 2 half-lives) = 0.30
|
||||
s.InDelta(0.30, score, 0.05, "14-day old feature should score ~0.30")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_PositiveFeedback() {
|
||||
// Positive feedback should boost score
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
UserFeedback: 1, // thumbs up
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Expected: (1.0 × 0.9) + 0.30 (feedback) = 1.20
|
||||
s.InDelta(1.20, score, 0.01, "thumbs up should boost score by 0.30")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_NegativeFeedback() {
|
||||
// Negative feedback should reduce score
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
UserFeedback: -1, // thumbs down
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Expected: (1.0 × 0.9) - 0.30 (feedback) = 0.60
|
||||
s.InDelta(0.60, score, 0.01, "thumbs down should reduce score by 0.30")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_WithConcepts() {
|
||||
// Observation with valuable concepts should get boost
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
Concepts: []string{"security", "gotcha"},
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Concept boost: (0.30 + 0.25) × 0.20 = 0.11
|
||||
// Expected: 1.3 + 0.11 = 1.41
|
||||
s.InDelta(1.41, score, 0.05, "security+gotcha concepts should boost score")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_WithRetrievals() {
|
||||
// Popular observations should get retrieval boost
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
RetrievalCount: 7, // log2(8) = 3
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Retrieval boost: log2(7+1) × 0.1 × 0.15 = 3 × 0.1 × 0.15 = 0.045
|
||||
// Expected: 1.1 + 0.045 ≈ 1.145
|
||||
s.InDelta(1.145, score, 0.05, "7 retrievals should add small boost")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_GoodScenarios_CombinedFactors() {
|
||||
// Test with all factors combined
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: s.now.Add(-7 * 24 * time.Hour).UnixMilli(), // 7 days old
|
||||
UserFeedback: 1,
|
||||
Concepts: []string{"security"},
|
||||
RetrievalCount: 3,
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Core: 1.0 × 1.3 × 0.5 = 0.65
|
||||
// Feedback: 0.30
|
||||
// Concept: 0.30 × 0.20 = 0.06
|
||||
// Retrieval: log2(4) × 0.1 × 0.15 = 2 × 0.1 × 0.15 = 0.03
|
||||
// Total ≈ 1.04
|
||||
s.InDelta(1.04, score, 0.1, "combined factors should result in ~1.04")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WORSE SCENARIOS - Degraded but acceptable operations
|
||||
// =============================================================================
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_WorseScenarios_VeryOldObservation() {
|
||||
// Very old observation should have low but non-zero score
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange,
|
||||
CreatedAtEpoch: s.now.Add(-90 * 24 * time.Hour).UnixMilli(), // 90 days old
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// 90 days = ~12.86 half-lives → decay ≈ 0.00014
|
||||
// Core: 1.0 × 0.9 × 0.00014 = 0.000126
|
||||
// But minimum score is 0.01
|
||||
s.GreaterOrEqual(score, 0.01, "very old observation should still meet minimum")
|
||||
s.Less(score, 0.1, "very old observation should be low scoring")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_WorseScenarios_NegativeFeedbackOld() {
|
||||
// Old observation with negative feedback should still have minimum score
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange,
|
||||
CreatedAtEpoch: s.now.Add(-60 * 24 * time.Hour).UnixMilli(),
|
||||
UserFeedback: -1,
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
s.GreaterOrEqual(score, s.config.MinScore, "should never go below minimum score")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_WorseScenarios_UnknownConcepts() {
|
||||
// Unknown concepts should not affect score negatively
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
Concepts: []string{"unknown-concept", "another-unknown"},
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Should just be the base score without concept boost
|
||||
s.InDelta(1.1, score, 0.01, "unknown concepts should not affect score")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_WorseScenarios_MixedConcepts() {
|
||||
// Mix of known and unknown concepts
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
Concepts: []string{"security", "unknown-concept"},
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Only security should contribute
|
||||
// Expected: 1.1 + (0.30 × 0.20) = 1.16
|
||||
s.InDelta(1.16, score, 0.05, "only known concepts should boost score")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BAD SCENARIOS - Edge cases and error conditions
|
||||
// =============================================================================
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_BadScenarios_FutureTimestamp() {
|
||||
// Observation created in the future (clock skew)
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: s.now.Add(24 * time.Hour).UnixMilli(), // 1 day in future
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Should handle gracefully - age should be 0
|
||||
s.InDelta(1.3, score, 0.01, "future timestamp should be treated as now")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_BadScenarios_ZeroEpoch() {
|
||||
// Missing creation timestamp
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAtEpoch: 0, // Missing timestamp
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// This will be treated as very old (1970)
|
||||
s.GreaterOrEqual(score, s.config.MinScore, "should still meet minimum")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_BadScenarios_EmptyObservation() {
|
||||
// Minimal observation with defaults
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: "", // Empty type
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Unknown type should default to 1.0 weight
|
||||
s.InDelta(1.0, score, 0.01, "empty type should use default weight 1.0")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_BadScenarios_ExtremeRetrievalCount() {
|
||||
// Very high retrieval count
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
RetrievalCount: 1000000, // Extreme value
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// log2(1000001) ≈ 19.93, so boost = 19.93 × 0.1 × 0.15 ≈ 0.30
|
||||
// Score should be reasonable, not exploding
|
||||
s.Less(score, 2.0, "extreme retrieval count should not explode score")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_BadScenarios_NegativeRetrievalCount() {
|
||||
// Negative retrieval count (should not happen but test defensively)
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
RetrievalCount: -5,
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// Should not panic and should give base score
|
||||
s.InDelta(1.1, score, 0.01, "negative retrieval should be ignored")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EDGE CASES - Boundary conditions
|
||||
// =============================================================================
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_EdgeCases_ExactlyOneHalfLife() {
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange, // 0.9 weight
|
||||
CreatedAtEpoch: s.now.Add(-7 * 24 * time.Hour).UnixMilli(),
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
s.InDelta(0.45, score, 0.01, "exactly 7 days should give 0.5 decay")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_EdgeCases_ExactlyTwoHalfLives() {
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange,
|
||||
CreatedAtEpoch: s.now.Add(-14 * 24 * time.Hour).UnixMilli(),
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
s.InDelta(0.225, score, 0.01, "exactly 14 days should give 0.25 decay")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_EdgeCases_AllTypeWeights() {
|
||||
types := []struct {
|
||||
t models.ObservationType
|
||||
weight float64
|
||||
}{
|
||||
{models.ObsTypeBugfix, 1.3},
|
||||
{models.ObsTypeFeature, 1.2},
|
||||
{models.ObsTypeDiscovery, 1.1},
|
||||
{models.ObsTypeDecision, 1.1},
|
||||
{models.ObsTypeRefactor, 1.0},
|
||||
{models.ObsTypeChange, 0.9},
|
||||
}
|
||||
|
||||
for _, tt := range types {
|
||||
s.Run(string(tt.t), func() {
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: tt.t,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
}
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
s.InDelta(tt.weight, score, 0.01)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_EdgeCases_MinimumScoreEnforced() {
|
||||
// Create worst case scenario
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange, // Lowest weight 0.9
|
||||
CreatedAtEpoch: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli(), // Very old
|
||||
UserFeedback: -1, // Negative feedback
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
s.Equal(s.config.MinScore, score, "should be exactly minimum score")
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculate_EdgeCases_AllConceptsMaxWeight() {
|
||||
// Observation with all high-value concepts
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: s.now.UnixMilli(),
|
||||
Concepts: []string{"security", "gotcha", "best-practice", "anti-pattern"},
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// security=0.30, gotcha=0.25, best-practice=0.20, anti-pattern=0.20 = 0.95
|
||||
// Concept contrib: 0.95 × 0.20 = 0.19
|
||||
// Total: 1.3 + 0.19 = 1.49
|
||||
s.InDelta(1.49, score, 0.05, "all high-value concepts should boost significantly")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CALCULATE COMPONENTS TESTS
|
||||
// =============================================================================
|
||||
|
||||
func (s *CalculatorSuite) TestCalculateComponents_ReturnsAllComponents() {
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: s.now.Add(-7 * 24 * time.Hour).UnixMilli(),
|
||||
UserFeedback: 1,
|
||||
Concepts: []string{"security"},
|
||||
RetrievalCount: 7,
|
||||
}
|
||||
|
||||
components := s.calc.CalculateComponents(obs, s.now)
|
||||
|
||||
s.InDelta(1.3, components.TypeWeight, 0.01)
|
||||
s.InDelta(0.5, components.RecencyDecay, 0.01)
|
||||
s.InDelta(0.65, components.CoreScore, 0.05)
|
||||
s.InDelta(0.30, components.FeedbackContrib, 0.01)
|
||||
s.InDelta(0.06, components.ConceptContrib, 0.02)
|
||||
s.Greater(components.RetrievalContrib, 0.0)
|
||||
s.InDelta(7.0, components.AgeDays, 0.1)
|
||||
s.Greater(components.FinalScore, 0.0)
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestCalculateComponents_MatchesCalculate() {
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeFeature,
|
||||
CreatedAtEpoch: s.now.Add(-3 * 24 * time.Hour).UnixMilli(),
|
||||
UserFeedback: -1,
|
||||
Concepts: []string{"performance", "architecture"},
|
||||
RetrievalCount: 15,
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
components := s.calc.CalculateComponents(obs, s.now)
|
||||
|
||||
s.InDelta(score, components.FinalScore, 0.001, "Calculate and CalculateComponents should match")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BATCH CALCULATE TESTS
|
||||
// =============================================================================
|
||||
|
||||
func (s *CalculatorSuite) TestBatchCalculate_Empty() {
|
||||
scores := s.calc.BatchCalculate(nil, s.now)
|
||||
s.Empty(scores)
|
||||
|
||||
scores = s.calc.BatchCalculate([]*models.Observation{}, s.now)
|
||||
s.Empty(scores)
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestBatchCalculate_Multiple() {
|
||||
obs := []*models.Observation{
|
||||
{ID: 1, Type: models.ObsTypeBugfix, CreatedAtEpoch: s.now.UnixMilli()},
|
||||
{ID: 2, Type: models.ObsTypeFeature, CreatedAtEpoch: s.now.Add(-7 * 24 * time.Hour).UnixMilli()},
|
||||
{ID: 3, Type: models.ObsTypeChange, CreatedAtEpoch: s.now.Add(-14 * 24 * time.Hour).UnixMilli()},
|
||||
}
|
||||
|
||||
scores := s.calc.BatchCalculate(obs, s.now)
|
||||
|
||||
s.Len(scores, 3)
|
||||
s.Contains(scores, int64(1))
|
||||
s.Contains(scores, int64(2))
|
||||
s.Contains(scores, int64(3))
|
||||
|
||||
s.InDelta(1.3, scores[1], 0.01) // New bugfix
|
||||
s.InDelta(0.6, scores[2], 0.1) // 7-day feature
|
||||
s.InDelta(0.225, scores[3], 0.05) // 14-day change
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION TESTS
|
||||
// =============================================================================
|
||||
|
||||
func (s *CalculatorSuite) TestNewCalculator_NilConfig() {
|
||||
calc := NewCalculator(nil)
|
||||
s.NotNil(calc)
|
||||
s.NotNil(calc.config)
|
||||
s.Equal(7.0, calc.config.RecencyHalfLifeDays)
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestUpdateConfig() {
|
||||
newConfig := &models.ScoringConfig{
|
||||
RecencyHalfLifeDays: 14.0, // Changed from 7
|
||||
FeedbackWeight: 0.50,
|
||||
ConceptWeight: 0.10,
|
||||
RetrievalWeight: 0.05,
|
||||
MinScore: 0.001,
|
||||
ConceptWeights: map[string]float64{"test": 0.5},
|
||||
}
|
||||
|
||||
s.calc.UpdateConfig(newConfig)
|
||||
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeChange,
|
||||
CreatedAtEpoch: s.now.Add(-14 * 24 * time.Hour).UnixMilli(),
|
||||
}
|
||||
|
||||
score := s.calc.Calculate(obs, s.now)
|
||||
|
||||
// With 14-day half-life, 14 days = exactly one half-life
|
||||
// Expected: 1.0 × 0.9 × 0.5 = 0.45
|
||||
s.InDelta(0.45, score, 0.01)
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestUpdateConfig_NilIgnored() {
|
||||
originalConfig := s.calc.GetConfig()
|
||||
s.calc.UpdateConfig(nil)
|
||||
s.Equal(originalConfig, s.calc.GetConfig())
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestGetConfig() {
|
||||
config := s.calc.GetConfig()
|
||||
s.NotNil(config)
|
||||
s.Equal(7.0, config.RecencyHalfLifeDays)
|
||||
}
|
||||
|
||||
func (s *CalculatorSuite) TestRecalculateThreshold() {
|
||||
threshold := s.calc.RecalculateThreshold()
|
||||
s.Equal(6*time.Hour, threshold)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STANDALONE TESTS (non-suite)
|
||||
// =============================================================================
|
||||
|
||||
func TestNewCalculator_DefaultConfig(t *testing.T) {
|
||||
calc := NewCalculator(nil)
|
||||
require.NotNil(t, calc)
|
||||
assert.Equal(t, 7.0, calc.config.RecencyHalfLifeDays)
|
||||
assert.Equal(t, 0.30, calc.config.FeedbackWeight)
|
||||
assert.Equal(t, 0.01, calc.config.MinScore)
|
||||
}
|
||||
|
||||
func TestCalculator_ConcurrentAccess(t *testing.T) {
|
||||
calc := NewCalculator(nil)
|
||||
now := time.Now()
|
||||
|
||||
// Test that calculator is safe for concurrent reads
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int64) {
|
||||
obs := &models.Observation{
|
||||
ID: id,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
score := calc.Calculate(obs, now)
|
||||
assert.Greater(t, score, 0.0)
|
||||
done <- true
|
||||
}(int64(i))
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculator_DecayPrecision(t *testing.T) {
|
||||
calc := NewCalculator(nil)
|
||||
now := time.Now()
|
||||
|
||||
// Test that decay is mathematically correct
|
||||
testCases := []struct {
|
||||
days int
|
||||
expectedDecay float64
|
||||
}{
|
||||
{0, 1.0},
|
||||
{7, 0.5},
|
||||
{14, 0.25},
|
||||
{21, 0.125},
|
||||
{28, 0.0625},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(string(rune('0'+tc.days/7))+"_half_lives", func(t *testing.T) {
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeRefactor, // 1.0 weight
|
||||
CreatedAtEpoch: now.Add(-time.Duration(tc.days) * 24 * time.Hour).UnixMilli(),
|
||||
}
|
||||
components := calc.CalculateComponents(obs, now)
|
||||
assert.InDelta(t, tc.expectedDecay, components.RecencyDecay, 0.001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeBaseScore_UnknownType(t *testing.T) {
|
||||
score := models.TypeBaseScore("unknown-type")
|
||||
assert.Equal(t, 1.0, score, "unknown type should default to 1.0")
|
||||
}
|
||||
|
||||
func TestTypeBaseScore_AllKnownTypes(t *testing.T) {
|
||||
expected := map[models.ObservationType]float64{
|
||||
models.ObsTypeBugfix: 1.3,
|
||||
models.ObsTypeFeature: 1.2,
|
||||
models.ObsTypeDiscovery: 1.1,
|
||||
models.ObsTypeDecision: 1.1,
|
||||
models.ObsTypeRefactor: 1.0,
|
||||
models.ObsTypeChange: 0.9,
|
||||
}
|
||||
|
||||
for obsType, expectedScore := range expected {
|
||||
t.Run(string(obsType), func(t *testing.T) {
|
||||
score := models.TypeBaseScore(obsType)
|
||||
assert.Equal(t, expectedScore, score)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculator_RetrievalBoostDiminishingReturns(t *testing.T) {
|
||||
calc := NewCalculator(nil)
|
||||
now := time.Now()
|
||||
|
||||
// Test that retrieval boost has diminishing returns
|
||||
// When retrieval count doubles, the boost should NOT double (log2 gives diminishing returns)
|
||||
|
||||
// Collect boosts for different counts
|
||||
boosts := make([]float64, 0)
|
||||
retrievalCounts := []int{1, 3, 7, 15, 31, 63, 127}
|
||||
|
||||
for _, count := range retrievalCounts {
|
||||
obs := &models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeRefactor,
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
RetrievalCount: count,
|
||||
}
|
||||
components := calc.CalculateComponents(obs, now)
|
||||
boosts = append(boosts, components.RetrievalContrib)
|
||||
}
|
||||
|
||||
// Verify boost increases but at a decreasing rate
|
||||
for i := 1; i < len(boosts); i++ {
|
||||
// Each boost should be higher than the previous
|
||||
assert.Greater(t, boosts[i], boosts[i-1],
|
||||
"boost should increase with more retrievals")
|
||||
|
||||
// But not proportionally - calculate the ratios
|
||||
if i >= 2 {
|
||||
ratio1 := boosts[i-1] / boosts[i-2]
|
||||
ratio2 := boosts[i] / boosts[i-1]
|
||||
// The growth ratio should be decreasing (diminishing returns)
|
||||
assert.Less(t, ratio2, ratio1+0.01, // Allow small floating point tolerance
|
||||
"growth rate should decrease (diminishing returns)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package scoring provides importance score calculation for observations.
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// ObservationStore defines the interface for observation storage operations needed by the recalculator.
|
||||
type ObservationStore interface {
|
||||
GetObservationsNeedingScoreUpdate(ctx context.Context, threshold time.Duration, limit int) ([]*models.Observation, error)
|
||||
UpdateImportanceScores(ctx context.Context, scores map[int64]float64) error
|
||||
GetConceptWeights(ctx context.Context) (map[string]float64, error)
|
||||
}
|
||||
|
||||
// Recalculator periodically recalculates importance scores for observations.
|
||||
type Recalculator struct {
|
||||
store ObservationStore
|
||||
calculator *Calculator
|
||||
log zerolog.Logger
|
||||
interval time.Duration
|
||||
batchSize int
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewRecalculator creates a new background recalculator.
|
||||
func NewRecalculator(store ObservationStore, calc *Calculator, log zerolog.Logger) *Recalculator {
|
||||
return &Recalculator{
|
||||
store: store,
|
||||
calculator: calc,
|
||||
log: log.With().Str("component", "recalculator").Logger(),
|
||||
interval: 1 * time.Hour, // Run every hour
|
||||
batchSize: 500, // Process 500 observations at a time
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the background recalculation loop.
|
||||
// This should be called in a goroutine.
|
||||
func (r *Recalculator) Start(ctx context.Context) {
|
||||
r.mu.Lock()
|
||||
if r.running {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.running = true
|
||||
r.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
r.running = false
|
||||
r.mu.Unlock()
|
||||
close(r.doneCh)
|
||||
}()
|
||||
|
||||
// Initial run
|
||||
r.recalculate(ctx)
|
||||
|
||||
r.mu.Lock()
|
||||
interval := r.interval
|
||||
r.mu.Unlock()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.log.Info().Msg("recalculator shutting down due to context cancellation")
|
||||
return
|
||||
case <-r.stopCh:
|
||||
r.log.Info().Msg("recalculator stopping")
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.recalculate(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the background recalculation loop.
|
||||
func (r *Recalculator) Stop() {
|
||||
r.mu.Lock()
|
||||
if !r.running {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
close(r.stopCh)
|
||||
<-r.doneCh
|
||||
}
|
||||
|
||||
// recalculate performs a single recalculation batch.
|
||||
func (r *Recalculator) recalculate(ctx context.Context) {
|
||||
now := time.Now()
|
||||
threshold := r.calculator.RecalculateThreshold()
|
||||
|
||||
r.mu.Lock()
|
||||
batchSize := r.batchSize
|
||||
r.mu.Unlock()
|
||||
|
||||
observations, err := r.store.GetObservationsNeedingScoreUpdate(ctx, threshold, batchSize)
|
||||
if err != nil {
|
||||
r.log.Error().Err(err).Msg("failed to get observations for score update")
|
||||
return
|
||||
}
|
||||
|
||||
if len(observations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
scores := r.calculator.BatchCalculate(observations, now)
|
||||
|
||||
if err := r.store.UpdateImportanceScores(ctx, scores); err != nil {
|
||||
r.log.Error().Err(err).Msg("failed to update importance scores")
|
||||
return
|
||||
}
|
||||
|
||||
r.log.Info().
|
||||
Int("count", len(scores)).
|
||||
Dur("elapsed", time.Since(now)).
|
||||
Msg("recalculated importance scores")
|
||||
}
|
||||
|
||||
// RecalculateNow triggers an immediate recalculation.
|
||||
// This is useful for testing or when scores need to be updated urgently.
|
||||
func (r *Recalculator) RecalculateNow(ctx context.Context) error {
|
||||
r.recalculate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshConceptWeights reloads concept weights from the database.
|
||||
// Call this after updating concept weights to apply changes.
|
||||
func (r *Recalculator) RefreshConceptWeights(ctx context.Context) error {
|
||||
weights, err := r.store.GetConceptWeights(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := r.calculator.GetConfig()
|
||||
config.ConceptWeights = weights
|
||||
r.calculator.UpdateConfig(config)
|
||||
|
||||
r.log.Info().Int("count", len(weights)).Msg("refreshed concept weights")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats returns statistics about the recalculator.
|
||||
type Stats struct {
|
||||
Running bool `json:"running"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
HalfLife float64 `json:"half_life_days"`
|
||||
MinScore float64 `json:"min_score"`
|
||||
ConceptsLen int `json:"concepts_count"`
|
||||
}
|
||||
|
||||
// GetStats returns current recalculator statistics.
|
||||
func (r *Recalculator) GetStats() Stats {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
config := r.calculator.GetConfig()
|
||||
|
||||
return Stats{
|
||||
Running: r.running,
|
||||
Interval: r.interval,
|
||||
BatchSize: r.batchSize,
|
||||
HalfLife: config.RecencyHalfLifeDays,
|
||||
MinScore: config.MinScore,
|
||||
ConceptsLen: len(config.ConceptWeights),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure ObservationStore satisfies the interface
|
||||
var _ ObservationStore = (*sqlite.ObservationStore)(nil)
|
||||
@@ -0,0 +1,447 @@
|
||||
// Package scoring provides importance score calculation for observations.
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// MockObservationStore is a mock implementation of ObservationStore for testing.
|
||||
type MockObservationStore struct {
|
||||
mu sync.Mutex
|
||||
observations []*models.Observation
|
||||
scores map[int64]float64
|
||||
conceptWeights map[string]float64
|
||||
updateErr error
|
||||
getErr error
|
||||
getConceptsErr error
|
||||
updateScoresCalls int
|
||||
}
|
||||
|
||||
func NewMockObservationStore() *MockObservationStore {
|
||||
return &MockObservationStore{
|
||||
observations: []*models.Observation{},
|
||||
scores: make(map[int64]float64),
|
||||
conceptWeights: make(map[string]float64),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) GetObservationsNeedingScoreUpdate(ctx context.Context, threshold time.Duration, limit int) ([]*models.Observation, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
|
||||
// Return observations that haven't been updated within threshold
|
||||
now := time.Now()
|
||||
var result []*models.Observation
|
||||
for _, obs := range m.observations {
|
||||
if !obs.ScoreUpdatedAt.Valid || now.Sub(time.Unix(obs.ScoreUpdatedAt.Int64, 0)) > threshold {
|
||||
result = append(result, obs)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) UpdateImportanceScores(ctx context.Context, scores map[int64]float64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.updateScoresCalls++
|
||||
if m.updateErr != nil {
|
||||
return m.updateErr
|
||||
}
|
||||
|
||||
for id, score := range scores {
|
||||
m.scores[id] = score
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) GetConceptWeights(ctx context.Context) (map[string]float64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.getConceptsErr != nil {
|
||||
return nil, m.getConceptsErr
|
||||
}
|
||||
return m.conceptWeights, nil
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) AddObservation(obs *models.Observation) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.observations = append(m.observations, obs)
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) SetConceptWeights(weights map[string]float64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.conceptWeights = weights
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) GetScore(id int64) (float64, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
score, ok := m.scores[id]
|
||||
return score, ok
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) GetUpdateScoresCalls() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.updateScoresCalls
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) SetUpdateError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.updateErr = err
|
||||
}
|
||||
|
||||
func (m *MockObservationStore) SetGetError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.getErr = err
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RECALCULATOR TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestNewRecalculator(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
require.NotNil(t, recalc)
|
||||
assert.NotNil(t, recalc.store)
|
||||
assert.NotNil(t, recalc.calculator)
|
||||
assert.Equal(t, 1*time.Hour, recalc.interval)
|
||||
assert.Equal(t, 500, recalc.batchSize)
|
||||
assert.False(t, recalc.running)
|
||||
}
|
||||
|
||||
func TestRecalculator_RecalculateNow(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
// Add observations
|
||||
now := time.Now()
|
||||
store.AddObservation(&models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
})
|
||||
store.AddObservation(&models.Observation{
|
||||
ID: 2,
|
||||
Type: models.ObsTypeFeature,
|
||||
CreatedAtEpoch: now.Add(-7 * 24 * time.Hour).UnixMilli(),
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := recalc.RecalculateNow(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify scores were calculated
|
||||
score1, ok := store.GetScore(1)
|
||||
assert.True(t, ok)
|
||||
assert.Greater(t, score1, 0.0)
|
||||
|
||||
score2, ok := store.GetScore(2)
|
||||
assert.True(t, ok)
|
||||
assert.Greater(t, score2, 0.0)
|
||||
assert.Less(t, score2, score1, "older observation should have lower score")
|
||||
}
|
||||
|
||||
func TestRecalculator_RefreshConceptWeights(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
// Set up custom weights in store
|
||||
customWeights := map[string]float64{
|
||||
"security": 0.50,
|
||||
"performance": 0.25,
|
||||
}
|
||||
store.SetConceptWeights(customWeights)
|
||||
|
||||
ctx := context.Background()
|
||||
err := recalc.RefreshConceptWeights(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify weights were updated in calculator config
|
||||
config := calc.GetConfig()
|
||||
assert.Equal(t, 0.50, config.ConceptWeights["security"])
|
||||
assert.Equal(t, 0.25, config.ConceptWeights["performance"])
|
||||
}
|
||||
|
||||
func TestRecalculator_RefreshConceptWeights_Error(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
store.getConceptsErr = assert.AnError
|
||||
|
||||
ctx := context.Background()
|
||||
err := recalc.RefreshConceptWeights(ctx)
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRecalculator_GetStats(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
// Set fields directly for testing
|
||||
recalc.interval = 2 * time.Hour
|
||||
recalc.batchSize = 250
|
||||
|
||||
stats := recalc.GetStats()
|
||||
|
||||
assert.False(t, stats.Running)
|
||||
assert.Equal(t, 2*time.Hour, stats.Interval)
|
||||
assert.Equal(t, 250, stats.BatchSize)
|
||||
assert.Equal(t, 7.0, stats.HalfLife)
|
||||
assert.Equal(t, 0.01, stats.MinScore)
|
||||
}
|
||||
|
||||
func TestRecalculator_StartStop(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
// Use a short interval for testing
|
||||
recalc.interval = 50 * time.Millisecond
|
||||
|
||||
// Add an observation
|
||||
store.AddObservation(&models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Start in goroutine
|
||||
go recalc.Start(ctx)
|
||||
|
||||
// Wait for initial run and at least one tick
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify it ran
|
||||
calls := store.GetUpdateScoresCalls()
|
||||
assert.GreaterOrEqual(t, calls, 1, "should have run at least once")
|
||||
|
||||
// Stop via context cancellation
|
||||
cancel()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify stopped
|
||||
stats := recalc.GetStats()
|
||||
assert.False(t, stats.Running)
|
||||
}
|
||||
|
||||
func TestRecalculator_StartTwice(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
recalc.interval = 1 * time.Hour // Long interval so it doesn't tick during test
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Start first time
|
||||
go recalc.Start(ctx)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Try to start second time (should return immediately)
|
||||
go recalc.Start(ctx)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Should still be running (only once)
|
||||
stats := recalc.GetStats()
|
||||
assert.True(t, stats.Running)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestRecalculator_StopWhenNotRunning(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
// Stop without starting - should not panic
|
||||
recalc.Stop()
|
||||
|
||||
stats := recalc.GetStats()
|
||||
assert.False(t, stats.Running)
|
||||
}
|
||||
|
||||
func TestRecalculator_EmptyStore(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
ctx := context.Background()
|
||||
err := recalc.RecalculateNow(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, store.GetUpdateScoresCalls(), "should not call update with no observations")
|
||||
}
|
||||
|
||||
func TestRecalculator_GetObservationsError(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
store.SetGetError(assert.AnError)
|
||||
|
||||
ctx := context.Background()
|
||||
err := recalc.RecalculateNow(ctx)
|
||||
|
||||
// Should not return error (logs it instead)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, store.GetUpdateScoresCalls())
|
||||
}
|
||||
|
||||
func TestRecalculator_UpdateScoresError(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
store.AddObservation(&models.Observation{
|
||||
ID: 1,
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
store.SetUpdateError(assert.AnError)
|
||||
|
||||
ctx := context.Background()
|
||||
err := recalc.RecalculateNow(ctx)
|
||||
|
||||
// Should not return error (logs it instead)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, store.GetUpdateScoresCalls())
|
||||
}
|
||||
|
||||
func TestRecalculator_BatchProcessing(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
// Set small batch size
|
||||
recalc.batchSize = 3
|
||||
|
||||
// Add 5 observations
|
||||
now := time.Now()
|
||||
for i := 1; i <= 5; i++ {
|
||||
store.AddObservation(&models.Observation{
|
||||
ID: int64(i),
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := recalc.RecalculateNow(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only process batch size (3)
|
||||
scores := 0
|
||||
for i := 1; i <= 5; i++ {
|
||||
if _, ok := store.GetScore(int64(i)); ok {
|
||||
scores++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 3, scores, "should only process batch size observations")
|
||||
}
|
||||
|
||||
func TestRecalculator_ConcurrentAccess(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
// Add observations
|
||||
now := time.Now()
|
||||
for i := 1; i <= 10; i++ {
|
||||
store.AddObservation(&models.Observation{
|
||||
ID: int64(i),
|
||||
Type: models.ObsTypeBugfix,
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Run multiple recalculations concurrently
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = recalc.RecalculateNow(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should complete without race conditions
|
||||
// (use -race flag to verify)
|
||||
assert.GreaterOrEqual(t, store.GetUpdateScoresCalls(), 1)
|
||||
}
|
||||
|
||||
func TestRecalculator_StatsThreadSafe(t *testing.T) {
|
||||
store := NewMockObservationStore()
|
||||
calc := NewCalculator(nil)
|
||||
log := zerolog.Nop()
|
||||
recalc := NewRecalculator(store, calc, log)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent reads (use -race flag to verify)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = recalc.GetStats()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user