mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
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.
161 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|