mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-08 23:39:40 +00:00
4f4b4ac70f
- [x] Add language-specific chunkers with AST parsing (Go, Python, TypeScript) - [x] Implement chunking manager to dispatch files to appropriate chunkers - [x] Integrate code chunks into vector sync for semantic search - [x] Add tree-sitter dependency for Python/TypeScript parsing - [x] Reorder struct fields for consistency across codebase - [x] Rename error variables to follow Go conventions (err → unmarshalErr, etc.) - [x] Add code chunk metadata to vector documents (language, symbol name, line ranges) - [x] Update worker service to initialize chunking pipeline with all three languages
448 lines
11 KiB
Go
448 lines
11 KiB
Go
// 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 {
|
|
updateErr error
|
|
getErr error
|
|
getConceptsErr error
|
|
scores map[int64]float64
|
|
conceptWeights map[string]float64
|
|
observations []*models.Observation
|
|
updateScoresCalls int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
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()
|
|
}
|