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:
2025-12-19 17:57:11 +00:00
committed by GitHub
parent 48957a6c81
commit f79782a008
69 changed files with 43967 additions and 194 deletions
+168
View File
@@ -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
}
+638
View File
@@ -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)")
}
}
}
+186
View File
@@ -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)
+447
View File
@@ -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()
}