diff --git a/desktop/frontend-next/src/port/memory.ts b/desktop/frontend-next/src/port/memory.ts index 7cf840ab6f..4306b548c2 100644 --- a/desktop/frontend-next/src/port/memory.ts +++ b/desktop/frontend-next/src/port/memory.ts @@ -19,6 +19,14 @@ export interface MemoryEntry { expired?: boolean; usedLastTurn?: boolean; why?: string; + galaxy?: string; + importance?: number; + state?: "clear" | "fuzzy" | "faded"; + uses?: number; + lastUsedAt?: string; + clearForDays?: number; + noDecay?: boolean; + new?: boolean; } // A memory file that is present but could not be read. It is reported beside @@ -46,4 +54,7 @@ export interface MemoryEdit { body: string; activation: string; keywords?: string; + galaxy?: string; + importance?: number; + type?: string; } diff --git a/desktop/frontend-next/src/port/mock.ts b/desktop/frontend-next/src/port/mock.ts index 3623569380..08d708c714 100644 --- a/desktop/frontend-next/src/port/mock.ts +++ b/desktop/frontend-next/src/port/mock.ts @@ -139,12 +139,11 @@ export class MockPort extends MockWorkspace implements AgentPort { async saveMemory(edit: MemoryEdit) { const at = this.mem.findIndex((m) => m.name === edit.name); if (at >= 0) { - // Keep what it replaced, the way the store does: a history panel driven - // by a fixture that overwrites would show one entry and prove nothing. - this.memPast[edit.name] = [...(this.memPast[edit.name] ?? [this.mem[at]]), this.mem[at]]; - this.mem[at] = { ...this.mem[at], title: edit.title, description: edit.description, - body: edit.body, activation: edit.activation, revision: (this.mem[at].revision ?? 1) + 1, - updatedAt: new Date().toISOString().slice(0, 10) }; + const cur = this.mem[at]; + this.memPast[edit.name] = [...(this.memPast[edit.name] ?? [cur]), cur]; + this.mem[at] = { ...cur, title: edit.title, description: edit.description, body: edit.body, + activation: edit.activation, revision: (cur.revision ?? 1) + 1, updatedAt: new Date().toISOString().slice(0, 10), + new: false, galaxy: edit.galaxy || cur.galaxy, importance: edit.importance || cur.importance, type: edit.type || cur.type }; } } @@ -161,8 +160,6 @@ export class MockPort extends MockWorkspace implements AgentPort { .map((m) => ({ ...m })); } - // Restoring appends a revision rather than rewinding to one, so the entry a - // reader was just looking at is still there afterwards. async restoreMemory(name: string, revision: number) { const at = this.mem.findIndex((m) => m.name === name); const old = (await this.memoryRevisions(name)).find((m) => (m.revision ?? 1) === revision); @@ -176,6 +173,9 @@ export class MockPort extends MockWorkspace implements AgentPort { async forgetMemory(name: string) { this.mem = this.mem.filter((m) => m.name !== name); } + async confirmMemory(name: string) { + this.mem = this.mem.map((m) => (m.name === name ? { ...m, new: false, state: "clear", uses: (m.uses ?? 0) + 1 } : m)); + } async versions(_refresh?: boolean): Promise { return { current: "dev", pinned: "", stalePin: false, latest: "", newer: false, versions: [] }; diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index 68f2f3b732..bcbf103a96 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -257,6 +257,7 @@ export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, Upload usage(query: UsageQuery): Promise; // Archives rather than deletes: a fact dropped by mistake stays recoverable. forgetMemory(name: string): Promise; + confirmMemory(name: string): Promise; saveMemory(edit: MemoryEdit): Promise; // Every save keeps the version it replaced. These are what let a reader see // that history and step back into it; restoring appends rather than rewinds, diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index ea4662649d..6397094d74 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -102,6 +102,9 @@ export class SsePort extends SseWorkspace implements AgentPort { forgetMemory(name: string) { return this.post("/memory/forget", { name }); } + confirmMemory(name: string) { + return this.post("/memory/confirm", { name }); + } async memoryRevisions(name: string) { const r = await this.get<{ revisions: MemoryEntry[] }>("/memory/revisions?name=" + encodeURIComponent(name)); return r.revisions ?? []; diff --git a/internal/frontend/serve/hub_settings.go b/internal/frontend/serve/hub_settings.go index 00cd73fa44..14e146f6c0 100644 --- a/internal/frontend/serve/hub_settings.go +++ b/internal/frontend/serve/hub_settings.go @@ -20,6 +20,7 @@ func (s *Server) registerMachineSettingsRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /memory", s.memories) mux.HandleFunc("POST /memory/forget", s.forgetMemory) mux.HandleFunc("POST /memory/save", s.saveMemory) + mux.HandleFunc("POST /memory/confirm", s.confirmMemory) mux.HandleFunc("GET /memory/revisions", s.memoryRevisions) mux.HandleFunc("POST /memory/restore", s.restoreMemory) mux.HandleFunc("GET /network", s.network) diff --git a/internal/frontend/serve/hub_settings_workspace_test.go b/internal/frontend/serve/hub_settings_workspace_test.go index 0af9e9f2a9..d18a577800 100644 --- a/internal/frontend/serve/hub_settings_workspace_test.go +++ b/internal/frontend/serve/hub_settings_workspace_test.go @@ -103,7 +103,7 @@ func TestMachineSettingsSelectLocalWorkspace(t *testing.T) { replacement := open("replacement", "home/chat-b") read(replacement.ID, "replacement") for _, id := range []string{second.ID, remote.ID, "missing"} { - for _, path := range []string{"/memory/save", "/memory/forget", "/memory/restore"} { + for _, path := range []string{"/memory/save", "/memory/forget", "/memory/confirm", "/memory/restore"} { w := request(id, http.MethodPost, path, `{"name":"fact","body":"wrong","revision":1}`, http.StatusServiceUnavailable) if !strings.Contains(w.Body.String(), `"settings.local_runtime_required"`) { t.Fatalf("invalid owner %s: %s", id, w.Body.String()) diff --git a/internal/frontend/serve/memory.go b/internal/frontend/serve/memory.go index 6a070b6510..75bbb303a2 100644 --- a/internal/frontend/serve/memory.go +++ b/internal/frontend/serve/memory.go @@ -2,6 +2,7 @@ package serve import ( "encoding/json" + "errors" "net/http" "strings" "time" @@ -29,6 +30,14 @@ type memoryEntry struct { // fact to the behaviour the user just saw. UsedLastTurn bool `json:"usedLastTurn,omitempty"` Why string `json:"why,omitempty"` + Galaxy string `json:"galaxy,omitempty"` + Importance int `json:"importance,omitempty"` + State string `json:"state,omitempty"` + Uses int `json:"uses,omitempty"` + LastUsedAt string `json:"lastUsedAt,omitempty"` + ClearForDays int `json:"clearForDays,omitempty"` + NoDecay bool `json:"noDecay,omitempty"` + New bool `json:"new,omitempty"` } // unreadableMemory is one fact file that is present but could not be read. It @@ -77,6 +86,9 @@ func (s *Server) memories(w http.ResponseWriter, r *http.Request) { CreatedAt: stamp(m.CreatedAt), UpdatedAt: stamp(m.UpdatedAt), Expired: !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(now), UsedLastTurn: hit, Why: reason, + Galaxy: m.Galaxy, Importance: m.ResolvedImportance(), + State: string(m.RetentionState(now)), Uses: m.Uses, LastUsedAt: stamp(m.LastUsedAt), + ClearForDays: m.ClearForDays(now), NoDecay: m.NoDecay(), New: m.AwaitingReview(now), }) } unreadable := make([]unreadableMemory, 0, len(report.Unreadable)) @@ -113,6 +125,30 @@ func (s *Server) forgetMemory(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (s *Server) confirmMemory(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" { + missingField(w, "name") + return + } + name := strings.TrimSpace(body.Name) + if s.ctl().Memory() == nil { + refuse(w, http.StatusConflict, "memory.unavailable", "memory is not enabled for this session", nil) + return + } + if err := s.ctl().ConfirmMemory(name); err != nil { + if errors.Is(err, memory.ErrNoSuchFact) { + notFound(w, "memory", name) + return + } + writeErr(w, http.StatusInternalServerError, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func stamp(t time.Time) string { if t.IsZero() { return "" @@ -130,6 +166,9 @@ type editable struct { Body string `json:"body"` Activation string `json:"activation"` Keywords string `json:"keywords"` + Galaxy string `json:"galaxy,omitempty"` + Importance int `json:"importance,omitempty"` + Type string `json:"type,omitempty"` } // saveMemory rewrites one fact in place. The store records it as a new audited @@ -147,6 +186,14 @@ func (s *Server) saveMemory(w http.ResponseWriter, r *http.Request) { badValue(w, "activation", "relevant, pinned") return } + if edit.Importance != 0 && memory.NormalizeImportance(edit.Importance) == 0 { + badValue(w, "importance", "1, 2, 3") + return + } + if t := strings.TrimSpace(edit.Type); t != "" && !memory.ValidType(t) { + badValue(w, "type", "user, feedback, project, reference, constraint") + return + } ctl := s.ctl() set := ctl.Memory() if set == nil { @@ -185,6 +232,15 @@ func (s *Server) saveMemory(w http.ResponseWriter, r *http.Request) { if activation != "" { current.Activation = activation } + if g := memory.NormalizeGalaxy(edit.Galaxy); g != "" { + current.Galaxy = g + } + if edit.Importance != 0 { + current.Importance = edit.Importance + } + if t := strings.TrimSpace(edit.Type); t != "" { + current.Type = memory.NormalizeType(t) + } path, err := ctl.SaveMemory(current) if err != nil { writeErr(w, http.StatusInternalServerError, err) diff --git a/internal/frontend/serve/memory_review_test.go b/internal/frontend/serve/memory_review_test.go new file mode 100644 index 0000000000..2d5739aac8 --- /dev/null +++ b/internal/frontend/serve/memory_review_test.go @@ -0,0 +1,115 @@ +package serve + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "reasonix/internal/session/control" + "reasonix/internal/state/memory" +) + +func memoryReviewServer(t *testing.T, facts ...memory.Memory) *Server { + t.Helper() + set := memory.Load(memory.Options{CWD: t.TempDir(), UserDir: t.TempDir()}) + for _, f := range facts { + if _, err := set.Store.Save(f); err != nil { + t.Fatal(err) + } + } + c := control.New(control.Options{Memory: memory.Load(set.LoadOptions())}) + t.Cleanup(c.Close) + return &Server{ctrl: c} +} + +func listedMemories(t *testing.T, s *Server) map[string]memoryEntry { + t.Helper() + rec := httptest.NewRecorder() + s.memories(rec, httptest.NewRequest(http.MethodGet, "/memory", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("list = %d %s", rec.Code, rec.Body) + } + var catalog struct { + Memories []memoryEntry `json:"memories"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &catalog); err != nil { + t.Fatal(err) + } + out := map[string]memoryEntry{} + for _, m := range catalog.Memories { + out[m.Name] = m + } + return out +} + +func postMemory(s *Server, handler func(http.ResponseWriter, *http.Request), path, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))) + return rec +} + +func TestTheMemoryListCarriesGalaxyStrengthAndNewness(t *testing.T) { + s := memoryReviewServer(t, + memory.Memory{Name: "deploy", Body: "deploys go through the platform branch", Galaxy: "release"}, + memory.Memory{Name: "rule", Body: "never force push", Type: memory.TypeConstraint}, + ) + got := listedMemories(t, s) + deploy := got["deploy"] + if deploy.Galaxy != "release" || deploy.Importance != memory.ImportanceNormal || deploy.State != "clear" { + t.Fatalf("deploy = %+v, want its galaxy, normal importance and a clear state", deploy) + } + if !deploy.New || deploy.ClearForDays <= 0 || deploy.NoDecay { + t.Fatalf("a fresh fact must read as new, decaying, with clear days left: %+v", deploy) + } + rule := got["rule"] + if !rule.NoDecay || rule.ClearForDays != 0 || rule.Importance != memory.ImportanceHigh { + t.Fatalf("a constraint must not decay and defaults to high importance: %+v", rule) + } +} + +func TestConfirmingAMemoryMarksItReviewedAndReinforcesIt(t *testing.T) { + s := memoryReviewServer(t, memory.Memory{Name: "deploy", Body: "deploys go through the platform branch"}) + if rec := postMemory(s, s.confirmMemory, "/memory/confirm", `{"name":"deploy"}`); rec.Code != http.StatusNoContent { + t.Fatalf("confirm = %d %s", rec.Code, rec.Body) + } + deploy := listedMemories(t, s)["deploy"] + if deploy.New || deploy.Uses != 1 || deploy.LastUsedAt == "" { + t.Fatalf("after confirm = %+v, want reviewed with one use", deploy) + } + rec := postMemory(s, s.confirmMemory, "/memory/confirm", `{"name":"missing"}`) + var reason Reason + if err := json.Unmarshal(rec.Body.Bytes(), &reason); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusNotFound || reason.Code != "request.not_found" { + t.Fatalf("unknown confirm = %d %+v, want 404 request.not_found", rec.Code, reason) + } + if rec := postMemory(s, s.confirmMemory, "/memory/confirm", `{}`); rec.Code != http.StatusBadRequest { + t.Fatalf("confirm without a name = %d", rec.Code) + } +} + +func TestSavingAMemoryMovesItsGalaxyImportanceAndType(t *testing.T) { + s := memoryReviewServer(t, memory.Memory{Name: "deploy", Body: "deploys go through the platform branch", Galaxy: "release"}) + body := `{"name":"deploy","body":"deploys go through the platform branch","galaxy":" ops ","importance":3,"type":"constraint"}` + if rec := postMemory(s, s.saveMemory, "/memory/save", body); rec.Code != http.StatusOK { + t.Fatalf("save = %d %s", rec.Code, rec.Body) + } + deploy := listedMemories(t, s)["deploy"] + if deploy.Galaxy != "ops" || deploy.Importance != 3 || deploy.Type != "constraint" || deploy.New { + t.Fatalf("after save = %+v, want galaxy ops, importance 3, a constraint, reviewed", deploy) + } + if rec := postMemory(s, s.saveMemory, "/memory/save", `{"name":"deploy","body":"x"}`); rec.Code != http.StatusOK { + t.Fatalf("save without galaxy = %d %s", rec.Code, rec.Body) + } + if got := listedMemories(t, s)["deploy"].Galaxy; got != "ops" { + t.Fatalf("a save that names no galaxy moved it to %q", got) + } + for _, bad := range []string{`{"name":"deploy","body":"x","importance":7}`, `{"name":"deploy","body":"x","type":"rumour"}`} { + if rec := postMemory(s, s.saveMemory, "/memory/save", bad); rec.Code != http.StatusBadRequest { + t.Fatalf("save %s = %d, want 400", bad, rec.Code) + } + } +} diff --git a/internal/session/control/memory.go b/internal/session/control/memory.go index 3e038d961e..05a5218ca0 100644 --- a/internal/session/control/memory.go +++ b/internal/session/control/memory.go @@ -293,9 +293,36 @@ func (m *memoryManager) saveMemory(fact memory.Memory) (string, error) { m.applyWrite(mem, "Saved memory \""+fact.Name+"\": "+strings.Join(strings.Fields(fact.Description), " ")+"\n"+strings.TrimSpace(fact.Body), result.Memory) + if err := mem.Store.MarkReviewed(result.Memory, time.Now()); err != nil { + return result.Path, err + } return result.Path, nil } +func (m *memoryManager) confirm(name string) error { + m.writeMu.Lock() + defer m.writeMu.Unlock() + mem := m.current() + if mem == nil { + return memory.ErrNoSuchFact + } + report, err := mem.Store.ListActive() + if err != nil { + return err + } + for _, fact := range report.Facts { + if fact.Name != name && fact.ID != name { + continue + } + now := time.Now() + if err := mem.Store.RecordUse([]memory.Memory{fact}, false, now); err != nil { + return err + } + return mem.Store.MarkReviewed(fact, now) + } + return memory.ErrNoSuchFact +} + // forget removes a saved auto-memory by name — the panel/TUI forget action, the // manual counterpart to the model's `forget` tool. It queues a turn-tail note so // the removal applies this session (the cached prefix still lists the fact until diff --git a/internal/session/control/memory_command.go b/internal/session/control/memory_command.go index 70ef5306dd..ab5e0837a2 100644 --- a/internal/session/control/memory_command.go +++ b/internal/session/control/memory_command.go @@ -567,6 +567,10 @@ func (c *Controller) ForgetMemory(name string) error { return c.memory.forget(name) } +func (c *Controller) ConfirmMemory(name string) error { + return c.memory.confirm(name) +} + // QueueMemory implements memory.Queue: when the model runs the remember/forget // tool, the tool calls this with the typed facts it wrote. The write refreshes // the snapshot a memory panel reads and records those facts as delivered, so the diff --git a/internal/session/control/port.go b/internal/session/control/port.go index 04eb1e5e3c..300d5e1c98 100644 --- a/internal/session/control/port.go +++ b/internal/session/control/port.go @@ -196,6 +196,7 @@ type MemoryControl interface { SaveDoc(path, body string) (string, error) SaveMemory(m memory.Memory) (string, error) ForgetMemory(name string) error + ConfirmMemory(name string) error QueueMemory(note string, delivered ...memory.Memory) MemoryRevisions(ref string) ([]memory.Memory, error) RestoreMemory(ref string, revision int) (memory.Memory, error) diff --git a/internal/state/memory/store.go b/internal/state/memory/store.go index 994e1a4c65..fa5462fb8c 100644 --- a/internal/state/memory/store.go +++ b/internal/state/memory/store.go @@ -47,6 +47,10 @@ const ( // normalises to TypeProject. var validTypes = map[Type]bool{TypeUser: true, TypeFeedback: true, TypeProject: true, TypeReference: true, TypeConstraint: true} +func ValidType(s string) bool { + return validTypes[Type(strings.ToLower(strings.TrimSpace(s)))] +} + // NormalizeType coerces an arbitrary string to a known Type, defaulting to // TypeProject so a sloppy tool argument never blocks a save. func NormalizeType(s string) Type { @@ -98,6 +102,7 @@ type Memory struct { Strength float64 LastUsedAt time.Time Uses int + ReviewedAt time.Time Body string } diff --git a/internal/state/memory/usage.go b/internal/state/memory/usage.go index ad6c1e3fe0..60d8633bfc 100644 --- a/internal/state/memory/usage.go +++ b/internal/state/memory/usage.go @@ -2,6 +2,7 @@ package memory import ( "encoding/json" + "errors" "maps" "math" "os" @@ -26,6 +27,7 @@ type usageRecord struct { Strength float64 `json:"strength,omitempty"` LastUsedAt time.Time `json:"last_used_at,omitempty"` Uses int `json:"uses,omitempty"` + ReviewedAt time.Time `json:"reviewed_at,omitempty"` } type usageData struct { @@ -102,6 +104,9 @@ func applyUsage(memories []Memory, byDir map[string]usageData, dirOf func(Memory if rec.Uses > 0 { memories[i].Uses = rec.Uses } + if !rec.ReviewedAt.IsZero() { + memories[i].ReviewedAt = rec.ReviewedAt + } } } @@ -209,16 +214,21 @@ func retentionWeight(r float64) float64 { return math.Max(r/RetentionClearMin, fadedWeightFloor) } -func (m Memory) Retention(now time.Time) float64 { - if m.NoDecay() { - return 1 - } +func (m Memory) retentionAnchor() time.Time { ref := m.LastUsedAt for _, t := range []time.Time{m.LastVerifiedAt, m.UpdatedAt, m.CreatedAt} { if t.After(ref) { ref = t } } + return ref +} + +func (m Memory) Retention(now time.Time) float64 { + if m.NoDecay() { + return 1 + } + ref := m.retentionAnchor() if ref.IsZero() { return 1 } @@ -293,3 +303,43 @@ func neighborsOf(seeds []RecallHit, docs []autoRecallDoc, links map[string]float } return out } + +const newFactWindow = 14 * 24 * time.Hour + +var ErrNoSuchFact = errors.New("memory.not_found") + +func (m Memory) AwaitingReview(now time.Time) bool { + return m.ReviewedAt.IsZero() && !m.CreatedAt.IsZero() && now.Sub(m.CreatedAt) < newFactWindow +} + +func (m Memory) ClearForDays(now time.Time) int { + if m.NoDecay() { + return 0 + } + ref := m.retentionAnchor() + if ref.IsZero() { + ref = now + } + left := m.StrengthDays()*math.Log(1/RetentionClearMin) - now.Sub(ref).Hours()/24 + if left <= 0 { + return 0 + } + return int(math.Ceil(left)) +} + +func (s Store) MarkReviewed(m Memory, now time.Time) error { + if (s.Dir == "" && s.GlobalDir == "") || m.ID == "" { + return nil + } + release, err := s.lockMutations() + if err != nil { + return err + } + defer release() + dir := s.usageDirFor(m) + data := readUsage(dir) + rec := data.Facts[m.ID] + rec.ReviewedAt = now.UTC() + data.Facts[m.ID] = rec + return writeUsage(dir, data) +} diff --git a/internal/state/memory/usage_test.go b/internal/state/memory/usage_test.go index 9715fc01ba..8978d64846 100644 --- a/internal/state/memory/usage_test.go +++ b/internal/state/memory/usage_test.go @@ -139,3 +139,43 @@ func TestOftenUsedTogetherBringsANeighbor(t *testing.T) { t.Fatalf("hits = %+v, want the matched fact and its frequent companion", result.Hits) } } + +func TestANewFactAwaitsReviewUntilMarked(t *testing.T) { + store := recallTestStore(t) + now := time.Now() + m := Memory{ID: "mem-n", Name: "n", Description: "n", Type: TypeProject, Scope: FactScopeProject, Body: "new fact", CreatedAt: now.Add(-time.Hour)} + recallTestWrite(t, store.Dir, m) + if !m.AwaitingReview(now) { + t.Fatal("a fact written an hour ago must await review") + } + if (Memory{CreatedAt: now.Add(-20 * 24 * time.Hour)}).AwaitingReview(now) { + t.Fatal("a fact older than the review window is not new") + } + if err := store.MarkReviewed(m, now); err != nil { + t.Fatal(err) + } + report, err := store.ListActive() + if err != nil || len(report.Facts) != 1 { + t.Fatalf("list = %+v, %v", report, err) + } + if report.Facts[0].AwaitingReview(now) { + t.Fatal("a reviewed fact still reads as new") + } +} + +func TestClearForDaysCountsDownToTheFuzzyLine(t *testing.T) { + now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC) + full := int(math.Ceil(30 * math.Log(1/RetentionClearMin))) + if got := (Memory{Type: TypeProject, CreatedAt: now}).ClearForDays(now); got != full { + t.Fatalf("fresh normal fact = %d days, want %d", got, full) + } + if got := (Memory{Type: TypeProject, CreatedAt: now.Add(-10 * 24 * time.Hour)}).ClearForDays(now); got != full-10 { + t.Fatalf("ten days on = %d, want %d", got, full-10) + } + if got := (Memory{Type: TypeProject, CreatedAt: now.Add(-60 * 24 * time.Hour)}).ClearForDays(now); got != 0 { + t.Fatalf("a fuzzy fact = %d days, want 0", got) + } + if got := (Memory{Type: TypeConstraint}).ClearForDays(now); got != 0 { + t.Fatalf("a constraint = %d days, want 0 since it never decays", got) + } +}