Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
| `infer_user_state` | `true` | — | — | Reserved for future user-state model inference. Currently accepted but ignored. |
| `user_state_turn_interval` | `5` | — | — | Turns between user-model inference passes (when the user-state model is active). |
| `user_state_max_pending` | `20` | — | — | Cap on pending user-model corrections queue. |
| `user_state_pending_max_age_days` | `14` | `0` disables | — | Drop unconfirmed pending corrections older than this many days (confirmed facts are never touched). |
| `associations_enabled` | `true` | — | — | Atom associations: semantic neighbors linked for co-recall. |
| `association_semantic_top_k` | `3` | — | — | Semantic neighbors linked per atom. |
| `proactive_return_after_break` | `true` | — | — | Return-after-break summary on the first turn after a gap. |
Expand Down
7 changes: 7 additions & 0 deletions internal/memory/extended/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Config struct {
InferUserState *bool `json:"infer_user_state,omitempty"`
UserStateTurnInterval int `json:"user_state_turn_interval,omitempty"`
UserStateMaxPending int `json:"user_state_max_pending,omitempty"`
UserStatePendingMaxAgeDays *int `json:"user_state_pending_max_age_days,omitempty"` // nil = default 14; 0 = never expire
AssociationsEnabled *bool `json:"associations_enabled,omitempty"`
AssociationSemanticTopK int `json:"association_semantic_top_k,omitempty"`
SemanticDedupThreshold *float32 `json:"semantic_dedup_threshold,omitempty"`
Expand Down Expand Up @@ -66,6 +67,8 @@ type LLMConfig struct {
// boolPtr returns a pointer to b.
func boolPtr(b bool) *bool { return &b }

func intPtr(i int) *int { return &i }

// floatPtr returns a pointer to f.
func floatPtr(f float32) *float32 { return &f }

Expand All @@ -89,6 +92,7 @@ func DefaultConfig() Config {
InferUserState: boolPtr(true),
UserStateTurnInterval: 5,
UserStateMaxPending: 20,
UserStatePendingMaxAgeDays: intPtr(14),
AssociationsEnabled: boolPtr(true),
AssociationSemanticTopK: 3,
SemanticDedupThreshold: floatPtr(0.92),
Expand Down Expand Up @@ -158,6 +162,9 @@ func Resolve(cfg Config) Config {
if cfg.UserStateMaxPending > 0 {
def.UserStateMaxPending = cfg.UserStateMaxPending
}
if cfg.UserStatePendingMaxAgeDays != nil {
def.UserStatePendingMaxAgeDays = cfg.UserStatePendingMaxAgeDays
}
if cfg.AssociationsEnabled != nil {
def.AssociationsEnabled = cfg.AssociationsEnabled
}
Expand Down
119 changes: 119 additions & 0 deletions internal/memory/extended/pending_expiry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package extended

import (
"context"
"strings"
"testing"
"time"
)

// ── B4 step 1: pending_review age expiry ────────────────────────────────
//
// Unconfirmed pending-review inferences accumulated until the count cap
// (default 20) pushed them out — and the count cap keeps the NEWEST, so
// stale chains rode in the protected memory head indefinitely. Age expiry
// drops unconfirmed entries older than the configured window. Confirmed
// facts are NOT touched — this is eviction of unconfirmed inferences only.
// A zero/negative age disables expiry (legacy behavior).

func TestApplyDiff_ExpiredPendingPruned(t *testing.T) {
u := NewUserModel()
u.cfg.UserStatePendingMaxAgeDays = intPtr(14)

old := time.Now().UTC().Add(-30 * 24 * time.Hour)
fresh := time.Now().UTC().Add(-2 * 24 * time.Hour)
u.state.PendingReview = []PendingReview{
{ID: "old", Field: "focus.blocker", Value: "stale blocker", CreatedAt: old},
{ID: "fresh", Field: "focus.blocker", Value: "current blocker", CreatedAt: fresh},
}

if err := u.applyDiff(context.Background(), userStateDiff{}); err != nil {
t.Fatalf("applyDiff: %v", err)
}

for _, p := range u.state.PendingReview {
if p.ID == "old" {
t.Error("30-day-old unconfirmed pending entry survived the 14-day expiry window")
}
}
found := false
for _, p := range u.state.PendingReview {
if p.ID == "fresh" {
found = true
}
}
if !found {
t.Error("2-day-old pending entry must survive the 14-day expiry window")
}
}

func TestApplyDiff_ZeroAgeDisablesExpiry(t *testing.T) {
u := NewUserModel()
u.cfg.UserStatePendingMaxAgeDays = intPtr(0) // explicit disable

old := time.Now().UTC().Add(-365 * 24 * time.Hour)
u.state.PendingReview = []PendingReview{
{ID: "ancient", Field: "focus.blocker", Value: "year-old inference", CreatedAt: old},
}

if err := u.applyDiff(context.Background(), userStateDiff{}); err != nil {
t.Fatalf("applyDiff: %v", err)
}
if len(u.state.PendingReview) != 1 {
t.Errorf("zero age must disable expiry (legacy behavior), pending = %d entries", len(u.state.PendingReview))
}
}

func TestApplyDiff_NewPendingNotExpiredBySameRun(t *testing.T) {
u := NewUserModel()
u.cfg.UserStatePendingMaxAgeDays = intPtr(14)

// A diff adding a fresh entry must not have it pruned in the same call
// (CreatedAt is stamped at apply time = now).
if err := u.applyDiff(context.Background(), userStateDiff{
Pending: []PendingReview{{Field: "style.tone", Value: "concise"}},
}); err != nil {
t.Fatalf("applyDiff: %v", err)
}
if len(u.state.PendingReview) != 1 {
t.Errorf("freshly inferred pending entry must survive its own applyDiff, pending = %d", len(u.state.PendingReview))
}
}

func TestSummary_ExpiredEntriesShrinkPendingBlock(t *testing.T) {
u := NewUserModel()
u.cfg.UserStatePendingMaxAgeDays = intPtr(14)

old := time.Now().UTC().Add(-60 * 24 * time.Hour)
u.state.PendingReview = []PendingReview{
{ID: "a", Field: "focus.blocker", Value: "stale-a", CreatedAt: old},
{ID: "b", Field: "focus.task", Value: "stale-b", CreatedAt: old},
}
if err := u.applyDiff(context.Background(), userStateDiff{}); err != nil {
t.Fatalf("applyDiff: %v", err)
}

summary := u.Summary()
if strings.Contains(summary, "Pending review (2)") {
t.Errorf("summary still advertises 2 pending entries after expiry:\n%.300s", summary)
}
}

// TestResolve_ZeroAgeDisables: the config contract '0 disables expiry'
// must survive Resolve — an explicit JSON 0 is a disable, not 'unset →
// default 14'. This pins the pointer-field merge (a plain int field with
// a != 0 guard made 0 unreachable, found by adversarial review).
func TestResolve_ZeroAgeDisables(t *testing.T) {
resolved := Resolve(Config{
UserStatePendingMaxAgeDays: intPtr(0),
})
if resolved.UserStatePendingMaxAgeDays == nil || *resolved.UserStatePendingMaxAgeDays != 0 {
t.Fatalf("Resolve(UserStatePendingMaxAgeDays=0) must keep 0 (disable), got %v", resolved.UserStatePendingMaxAgeDays)
}

// Absent = default 14.
resolved = Resolve(Config{})
if resolved.UserStatePendingMaxAgeDays == nil || *resolved.UserStatePendingMaxAgeDays != 14 {
t.Fatalf("Resolve(absent) must yield default 14, got %v", resolved.UserStatePendingMaxAgeDays)
}
}
16 changes: 16 additions & 0 deletions internal/memory/extended/usermodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,22 @@ func (u *UserModel) applyDiff(ctx context.Context, diff userStateDiff) error {
}
u.state.PendingReview = append(u.state.PendingReview, p)
}
// Age expiry FIRST, then the count trim: a stale entry occupying a cap
// slot must not push a fresh entry out before the stale one is dropped.
// Unconfirmed inferences older than the window are dropped (the count
// cap alone keeps the NEWEST, letting stale chains ride in the memory
// head indefinitely). 0/negative disables expiry. Confirmed facts are
// never touched here — this prunes PendingReview only.
if maxAge := u.cfg.UserStatePendingMaxAgeDays; maxAge != nil && *maxAge > 0 {
cutoff := time.Now().UTC().AddDate(0, 0, -*maxAge)
kept := u.state.PendingReview[:0]
for _, p := range u.state.PendingReview {
if p.CreatedAt.IsZero() || p.CreatedAt.After(cutoff) {
kept = append(kept, p)
}
}
u.state.PendingReview = kept
}
if len(u.state.PendingReview) > maxPending {
u.state.PendingReview = u.state.PendingReview[len(u.state.PendingReview)-maxPending:]
}
Expand Down
Loading