From 80eaff0af0392ab0140b3fb38089a1b931085a72 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:52:03 +0200 Subject: [PATCH 1/2] feat: pending_review age expiry (B4 step 1) --- docs/CONFIG.md | 1 + internal/memory/extended/config.go | 5 + .../memory/extended/pending_expiry_test.go | 100 ++++++++++++++++++ internal/memory/extended/usermodel.go | 14 +++ 4 files changed, 120 insertions(+) create mode 100644 internal/memory/extended/pending_expiry_test.go diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d3ad5c2c..8da7e25d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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. | diff --git a/internal/memory/extended/config.go b/internal/memory/extended/config.go index 5a2f7109..1d7ff3f9 100644 --- a/internal/memory/extended/config.go +++ b/internal/memory/extended/config.go @@ -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"` AssociationsEnabled *bool `json:"associations_enabled,omitempty"` AssociationSemanticTopK int `json:"association_semantic_top_k,omitempty"` SemanticDedupThreshold *float32 `json:"semantic_dedup_threshold,omitempty"` @@ -89,6 +90,7 @@ func DefaultConfig() Config { InferUserState: boolPtr(true), UserStateTurnInterval: 5, UserStateMaxPending: 20, + UserStatePendingMaxAgeDays: 14, AssociationsEnabled: boolPtr(true), AssociationSemanticTopK: 3, SemanticDedupThreshold: floatPtr(0.92), @@ -158,6 +160,9 @@ func Resolve(cfg Config) Config { if cfg.UserStateMaxPending > 0 { def.UserStateMaxPending = cfg.UserStateMaxPending } + if cfg.UserStatePendingMaxAgeDays != 0 { + def.UserStatePendingMaxAgeDays = cfg.UserStatePendingMaxAgeDays + } if cfg.AssociationsEnabled != nil { def.AssociationsEnabled = cfg.AssociationsEnabled } diff --git a/internal/memory/extended/pending_expiry_test.go b/internal/memory/extended/pending_expiry_test.go new file mode 100644 index 00000000..7b575d02 --- /dev/null +++ b/internal/memory/extended/pending_expiry_test.go @@ -0,0 +1,100 @@ +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 = 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 = 0 // legacy: never expire + + 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 = 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 = 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) + } +} diff --git a/internal/memory/extended/usermodel.go b/internal/memory/extended/usermodel.go index f0905b61..242032b4 100644 --- a/internal/memory/extended/usermodel.go +++ b/internal/memory/extended/usermodel.go @@ -332,6 +332,20 @@ func (u *UserModel) applyDiff(ctx context.Context, diff userStateDiff) error { if len(u.state.PendingReview) > maxPending { u.state.PendingReview = u.state.PendingReview[len(u.state.PendingReview)-maxPending:] } + // Age expiry: 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 maxAgeDays := u.cfg.UserStatePendingMaxAgeDays; maxAgeDays > 0 { + cutoff := time.Now().UTC().AddDate(0, 0, -maxAgeDays) + 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 + } return nil } From c127f5f45391607e38bcebf8be3ada97a7cc1a80 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:03:15 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20pending=20expiry=20review=20round=20?= =?UTF-8?q?=E2=80=94=20pointer=20config=20field,=20expiry-before-trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserStatePendingMaxAgeDays is *int now: explicit JSON 0 disables expiry (a plain int with a != 0 Resolve guard made 0 unreachable — MAJOR finding confirmed by all three judges); nil = default 14 - age expiry runs BEFORE the maxPending count trim so a stale entry can no longer evict a fresh one from a cap slot - TestResolve_ZeroAgeDisables pins the JSON->Resolve->disable contract --- internal/memory/extended/config.go | 8 +++--- .../memory/extended/pending_expiry_test.go | 27 ++++++++++++++++--- internal/memory/extended/usermodel.go | 20 +++++++------- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/internal/memory/extended/config.go b/internal/memory/extended/config.go index 1d7ff3f9..5cc93a00 100644 --- a/internal/memory/extended/config.go +++ b/internal/memory/extended/config.go @@ -32,7 +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"` + 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"` @@ -67,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 } @@ -90,7 +92,7 @@ func DefaultConfig() Config { InferUserState: boolPtr(true), UserStateTurnInterval: 5, UserStateMaxPending: 20, - UserStatePendingMaxAgeDays: 14, + UserStatePendingMaxAgeDays: intPtr(14), AssociationsEnabled: boolPtr(true), AssociationSemanticTopK: 3, SemanticDedupThreshold: floatPtr(0.92), @@ -160,7 +162,7 @@ func Resolve(cfg Config) Config { if cfg.UserStateMaxPending > 0 { def.UserStateMaxPending = cfg.UserStateMaxPending } - if cfg.UserStatePendingMaxAgeDays != 0 { + if cfg.UserStatePendingMaxAgeDays != nil { def.UserStatePendingMaxAgeDays = cfg.UserStatePendingMaxAgeDays } if cfg.AssociationsEnabled != nil { diff --git a/internal/memory/extended/pending_expiry_test.go b/internal/memory/extended/pending_expiry_test.go index 7b575d02..61a985c4 100644 --- a/internal/memory/extended/pending_expiry_test.go +++ b/internal/memory/extended/pending_expiry_test.go @@ -18,7 +18,7 @@ import ( func TestApplyDiff_ExpiredPendingPruned(t *testing.T) { u := NewUserModel() - u.cfg.UserStatePendingMaxAgeDays = 14 + u.cfg.UserStatePendingMaxAgeDays = intPtr(14) old := time.Now().UTC().Add(-30 * 24 * time.Hour) fresh := time.Now().UTC().Add(-2 * 24 * time.Hour) @@ -49,7 +49,7 @@ func TestApplyDiff_ExpiredPendingPruned(t *testing.T) { func TestApplyDiff_ZeroAgeDisablesExpiry(t *testing.T) { u := NewUserModel() - u.cfg.UserStatePendingMaxAgeDays = 0 // legacy: never expire + u.cfg.UserStatePendingMaxAgeDays = intPtr(0) // explicit disable old := time.Now().UTC().Add(-365 * 24 * time.Hour) u.state.PendingReview = []PendingReview{ @@ -66,7 +66,7 @@ func TestApplyDiff_ZeroAgeDisablesExpiry(t *testing.T) { func TestApplyDiff_NewPendingNotExpiredBySameRun(t *testing.T) { u := NewUserModel() - u.cfg.UserStatePendingMaxAgeDays = 14 + 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). @@ -82,7 +82,7 @@ func TestApplyDiff_NewPendingNotExpiredBySameRun(t *testing.T) { func TestSummary_ExpiredEntriesShrinkPendingBlock(t *testing.T) { u := NewUserModel() - u.cfg.UserStatePendingMaxAgeDays = 14 + u.cfg.UserStatePendingMaxAgeDays = intPtr(14) old := time.Now().UTC().Add(-60 * 24 * time.Hour) u.state.PendingReview = []PendingReview{ @@ -98,3 +98,22 @@ func TestSummary_ExpiredEntriesShrinkPendingBlock(t *testing.T) { 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) + } +} diff --git a/internal/memory/extended/usermodel.go b/internal/memory/extended/usermodel.go index 242032b4..97d80f84 100644 --- a/internal/memory/extended/usermodel.go +++ b/internal/memory/extended/usermodel.go @@ -329,15 +329,14 @@ func (u *UserModel) applyDiff(ctx context.Context, diff userStateDiff) error { } u.state.PendingReview = append(u.state.PendingReview, p) } - if len(u.state.PendingReview) > maxPending { - u.state.PendingReview = u.state.PendingReview[len(u.state.PendingReview)-maxPending:] - } - // Age expiry: 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 maxAgeDays := u.cfg.UserStatePendingMaxAgeDays; maxAgeDays > 0 { - cutoff := time.Now().UTC().AddDate(0, 0, -maxAgeDays) + // 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) { @@ -346,6 +345,9 @@ func (u *UserModel) applyDiff(ctx context.Context, diff userStateDiff) error { } u.state.PendingReview = kept } + if len(u.state.PendingReview) > maxPending { + u.state.PendingReview = u.state.PendingReview[len(u.state.PendingReview)-maxPending:] + } return nil }