Files
viettranx 4cf66eb379 feat(ts-port): reasoning strip, dreaming config + weighted scoring
Phase 6 — Reasoning token stripping:
- ReasoningDecision.StripThinking auto-flags Kimi + DeepSeek-Reasoner
- Guard clauses in Anthropic/OpenAI/Codex stream handlers
- Usage.ThinkingTokens + RawAssistantContent preserved (billing + tool passback safe)

Phase 8 — Per-agent dreaming config:
- MemoryConfig.Dreaming JSONB (no migration), resolver callback pattern
- Enabled/DebounceMs/Threshold/VerboseLog fields with partial-override merge
- ConsolidationDeps gains optional AgentStore

Phase 10 — Dreaming weighted scoring:
- Migration 000045 adds recall_count/recall_score/last_recalled_at on episodic_summaries
- ComputeRecallScore 4-component formula (freq/rel/recency/freshness, 14d half-life)
- memory_search fire-and-forget RecordRecall; ListUnpromotedScored in DreamingWorker
- Bootstrap-friendly filter: unrecalled entries bypass thresholds
- Debounce stamped on filter-empty skip to prevent starvation loop

Phase 5 follow-up — last_compaction_at in sessions.metadata JSONB:
- v3 PruneStage.CompactMessages and v2 maybeSummarize both stamp timestamp
- Zero migration; exported const SessionMetaKeyLastCompactionAt

RequiredSchemaVersion: 44 → 45 (PG), SchemaVersion: 12 → 13 (SQLite).
27 new tests; builds pass under PG and sqliteonly tags.
2026-04-10 13:32:02 +07:00

161 lines
5.7 KiB
Go

package consolidation
import (
"math"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// floatEq checks two floats are equal within epsilon for the scoring assertions.
func floatEq(a, b, eps float64) bool { return math.Abs(a-b) < eps }
func TestComputeRecallScoreZeroRecallFreshEntry(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
entry := store.EpisodicSummary{
ID: uuid.New(),
CreatedAt: now, // brand new, zero recalls
}
// freq=0, rel=0, recency=1.0, freshness=1.0
// 0.30*0 + 0.35*0 + 0.20*1 + 0.15*1 = 0.35
score := ComputeRecallScore(entry, now)
if !floatEq(score, 0.35, 0.001) {
t.Errorf("score = %f, want ~0.35", score)
}
}
func TestComputeRecallScoreOldUnrecalled(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
entry := store.EpisodicSummary{
CreatedAt: now.AddDate(0, 0, -28), // 28 days old, never recalled
}
// recency = freshness = exp(-ln2/14 * 28) = exp(-ln2*2) = 0.25
// score = 0 + 0 + 0.2*0.25 + 0.15*0.25 = 0.0875
score := ComputeRecallScore(entry, now)
if !floatEq(score, 0.0875, 0.001) {
t.Errorf("score = %f, want ~0.0875", score)
}
}
func TestComputeRecallScoreExponentialDecayHalfLife(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
// Entry recalled exactly 14 days ago (one half-life). Recency should halve.
lastRecall := now.AddDate(0, 0, -14)
entry := store.EpisodicSummary{
CreatedAt: lastRecall, // created at same moment it was last recalled
RecallCount: 1,
RecallScore: 1.0,
LastRecalledAt: &lastRecall,
}
// freq = log1p(1) / log1p(10) = 0.693/2.398 ≈ 0.289
// rel = 1.0
// recency = freshness = exp(-ln2) = 0.5
// score = 0.30*0.289 + 0.35*1.0 + 0.20*0.5 + 0.15*0.5 = 0.0867 + 0.35 + 0.175 = 0.6117
score := ComputeRecallScore(entry, now)
if !floatEq(score, 0.6117, 0.001) {
t.Errorf("score = %f, want ~0.6117", score)
}
}
func TestComputeRecallScoreHighRecallSignal(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
entry := store.EpisodicSummary{
CreatedAt: now.AddDate(0, 0, -3), // 3 days old
RecallCount: 10,
RecallScore: 0.9,
LastRecalledAt: &now, // recalled today
}
// freq ≈ 1.0 (cap)
// rel = 0.9
// recency ≈ 1.0 (lastRef=now)
// freshness = exp(-ln2/14 * 3) ≈ 0.862
// score ≈ 0.30 + 0.315 + 0.20 + 0.1293 ≈ 0.944
score := ComputeRecallScore(entry, now)
if score < 0.9 {
t.Errorf("score = %f, want >0.9 (hot entry)", score)
}
}
func TestComputeRecallScoreClampsRelevance(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
// Malformed entry with out-of-range recall_score — scoring must clamp.
entry := store.EpisodicSummary{
CreatedAt: now,
RecallCount: 1,
RecallScore: 2.5, // bug: > 1.0
}
score := ComputeRecallScore(entry, now)
if score > 1.1 {
t.Errorf("score = %f exceeds reasonable bounds (relevance should clamp)", score)
}
}
func TestFilterByRecallThresholdsFreshBypass(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
entries := []store.EpisodicSummary{
// Never recalled, 28 days old → low score but bypasses via RecallCount=0.
{ID: uuid.New(), CreatedAt: now.AddDate(0, 0, -28)},
}
kept := filterByRecallThresholds(entries, recallThresholds{MinScore: 0.5, MinRecallCount: 2}, now)
if len(kept) != 1 {
t.Errorf("expected 1 kept (fresh bypass), got %d", len(kept))
}
}
func TestFilterByRecallThresholdsPreservesOrder(t *testing.T) {
// In-place filter must not scramble the caller's ORDER BY recall_score DESC.
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
entries := []store.EpisodicSummary{
{ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), CreatedAt: now, RecallCount: 10, RecallScore: 0.9, LastRecalledAt: &now},
{ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"), CreatedAt: now, RecallCount: 1, RecallScore: 0.1}, // filtered
{ID: uuid.MustParse("00000000-0000-0000-0000-000000000003"), CreatedAt: now, RecallCount: 5, RecallScore: 0.7, LastRecalledAt: &now},
}
kept := filterByRecallThresholds(entries, defaultRecallThresholds(), now)
if len(kept) != 2 {
t.Fatalf("len=%d, want 2", len(kept))
}
if kept[0].ID.String() != "00000000-0000-0000-0000-000000000001" ||
kept[1].ID.String() != "00000000-0000-0000-0000-000000000003" {
t.Errorf("order corrupted: %v", []string{kept[0].ID.String(), kept[1].ID.String()})
}
}
func TestFilterByRecallThresholdsDropsWeakRecalled(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
entries := []store.EpisodicSummary{
// Recalled once — below MinRecallCount=2 → filtered.
{ID: uuid.New(), CreatedAt: now, RecallCount: 1, RecallScore: 0.9},
// Recalled 3 times with good score — kept.
{ID: uuid.New(), CreatedAt: now, RecallCount: 3, RecallScore: 0.8, LastRecalledAt: &now},
// Never recalled — kept via freshness.
{ID: uuid.New(), CreatedAt: now},
}
kept := filterByRecallThresholds(entries, defaultRecallThresholds(), now)
if len(kept) != 2 {
t.Errorf("expected 2 kept, got %d", len(kept))
}
}
func TestFormatEntryForSynthesisAnnotatesRecalled(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
entry := store.EpisodicSummary{
Summary: "test session",
RecallCount: 5,
LastRecalledAt: &now,
}
out := formatEntryForSynthesis(entry)
if !strings.Contains(out, "recalled 5x") || !strings.Contains(out, "test session") {
t.Errorf("output missing metadata or summary: %q", out)
}
}
func TestFormatEntryForSynthesisBypassesUnrecalled(t *testing.T) {
entry := store.EpisodicSummary{Summary: "fresh content"}
if out := formatEntryForSynthesis(entry); out != "fresh content" {
t.Errorf("unrecalled entry should pass through unchanged, got %q", out)
}
}