Skip to content

Commit 06ba2ea

Browse files
authored
Merge pull request #20 from lazypower/feat/issue-12-stabilization
Stabilize retraction invariant: read + write guards
2 parents 78aec93 + 1a437f3 commit 06ba2ea

12 files changed

Lines changed: 871 additions & 5 deletions

File tree

internal/engine/extractor.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ func findSimilarNode(ctx context.Context, db *store.DB, embedder Embedder,
8585
if !ok || node.NodeType != "leaf" || node.Category != category {
8686
continue
8787
}
88+
// Retracted nodes must not influence similarity selection. Returning
89+
// one as a merge target lets a later UpsertNode silently overwrite the
90+
// retracted row's content — resurrection through the back door. The
91+
// dedup-against-retracted gate (engine.findRetractedMatches) is a
92+
// separate path that intentionally finds these; this one must not.
93+
if node.IsRetracted() {
94+
continue
95+
}
8896

8997
sim := CosineSimilarity(candidateVec, v.Embedding)
9098
if sim > bestSim && sim >= threshold {
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/lazypower/continuity/internal/llm"
9+
"github.com/lazypower/continuity/internal/store"
10+
)
11+
12+
// snapshotNode captures the full row state of a node for byte-equal comparison
13+
// after running potentially-mutating paths. The retraction contract says
14+
// retracted nodes are inert except via explicit URI inspection — every column
15+
// must be unchanged after any non-inspection write path runs.
16+
func snapshotNode(t *testing.T, db *store.DB, uri string) store.MemNode {
17+
t.Helper()
18+
n, err := db.GetNodeByURI(uri)
19+
if err != nil {
20+
t.Fatalf("snapshot %s: %v", uri, err)
21+
}
22+
if n == nil {
23+
t.Fatalf("snapshot %s: node not found", uri)
24+
}
25+
// Dereference the pointer fields so the snapshot doesn't share storage with
26+
// the live row. TombstonedAt is the only pointer field on MemNode aside
27+
// from LastAccess.
28+
snap := *n
29+
if n.TombstonedAt != nil {
30+
v := *n.TombstonedAt
31+
snap.TombstonedAt = &v
32+
}
33+
if n.LastAccess != nil {
34+
v := *n.LastAccess
35+
snap.LastAccess = &v
36+
}
37+
return snap
38+
}
39+
40+
// assertNoResurrection compares a fresh read of `uri` against `before` and
41+
// fails if any field has changed. Equality is structural — two pointer fields
42+
// are considered equal if their pointed-to values match.
43+
func assertNoResurrection(t *testing.T, db *store.DB, uri string, before store.MemNode) {
44+
t.Helper()
45+
after, err := db.GetNodeByURI(uri)
46+
if err != nil {
47+
t.Fatalf("read after: %v", err)
48+
}
49+
if after == nil {
50+
t.Fatalf("retracted node %s disappeared (this is also a violation)", uri)
51+
}
52+
53+
// Compare value fields directly.
54+
if !nodesEqualByValue(before, *after) {
55+
t.Errorf("retracted node mutated after a write path ran:\n before: %+v\n after: %+v",
56+
fmtNode(before), fmtNode(*after))
57+
}
58+
}
59+
60+
func nodesEqualByValue(a, b store.MemNode) bool {
61+
// All scalar fields must match.
62+
if a.ID != b.ID || a.URI != b.URI || a.ParentURI != b.ParentURI ||
63+
a.NodeType != b.NodeType || a.Category != b.Category ||
64+
a.L0Abstract != b.L0Abstract || a.L1Overview != b.L1Overview ||
65+
a.L2Content != b.L2Content || a.Mergeable != b.Mergeable ||
66+
a.MergedFrom != b.MergedFrom || a.Relevance != b.Relevance ||
67+
a.AccessCount != b.AccessCount || a.SourceSession != b.SourceSession ||
68+
a.CreatedAt != b.CreatedAt || a.UpdatedAt != b.UpdatedAt ||
69+
a.TombstoneReason != b.TombstoneReason || a.SupersededBy != b.SupersededBy {
70+
return false
71+
}
72+
// Pointer fields: equal if both nil or both non-nil with same pointed value.
73+
if !pointerEqualInt64(a.TombstonedAt, b.TombstonedAt) {
74+
return false
75+
}
76+
if !pointerEqualInt64(a.LastAccess, b.LastAccess) {
77+
return false
78+
}
79+
return true
80+
}
81+
82+
func pointerEqualInt64(a, b *int64) bool {
83+
if a == nil && b == nil {
84+
return true
85+
}
86+
if a == nil || b == nil {
87+
return false
88+
}
89+
return *a == *b
90+
}
91+
92+
func fmtNode(n store.MemNode) string {
93+
tombstoned := "<nil>"
94+
if n.TombstonedAt != nil {
95+
tombstoned = fmt.Sprintf("%d", *n.TombstonedAt)
96+
}
97+
return fmt.Sprintf("URI=%s L0=%q L1=%q TombstonedAt=%s Reason=%q SupersededBy=%q UpdatedAt=%d",
98+
n.URI, n.L0Abstract, n.L1Overview, tombstoned, n.TombstoneReason, n.SupersededBy, n.UpdatedAt)
99+
}
100+
101+
// TestNoResurrection_FindSimilarNodeDoesNotReturnRetracted is the unit-level
102+
// guard. findSimilarNode is called by the LLM extraction path; if it returns
103+
// a retracted node, the caller will merge new content into the retracted URI
104+
// and effectively un-retract it.
105+
func TestNoResurrection_FindSimilarNodeDoesNotReturnRetracted(t *testing.T) {
106+
db := testDB(t)
107+
ctx := context.Background()
108+
109+
// Seed a node and embed it, then retract it.
110+
n := &store.MemNode{
111+
URI: "mem://user/preferences/retracted-pref", NodeType: "leaf", Category: "preferences",
112+
L0Abstract: "Prefers minimal dependencies and standard library where possible",
113+
L1Overview: "Body content here for validation thresholds and assertions to land.",
114+
}
115+
if err := db.CreateNode(n); err != nil {
116+
t.Fatal(err)
117+
}
118+
embedder, err := NewTFIDFEmbedder(db, 512)
119+
if err != nil {
120+
t.Fatalf("NewTFIDFEmbedder: %v", err)
121+
}
122+
vec, err := embedder.Embed(ctx, n.L0Abstract)
123+
if err != nil {
124+
t.Fatalf("Embed: %v", err)
125+
}
126+
if err := db.SaveVector(n.ID, vec, embedder.Model()); err != nil {
127+
t.Fatalf("SaveVector: %v", err)
128+
}
129+
if _, err := db.RetractNode(n.URI, "test retraction", ""); err != nil {
130+
t.Fatal(err)
131+
}
132+
133+
// Search for a semantically very similar candidate. findSimilarNode must
134+
// NOT return the retracted node — even though it's the closest match.
135+
match, sim, err := findSimilarNode(ctx, db, embedder,
136+
"Prefers minimal dependencies and standard library where possible", "preferences", 0.5)
137+
if err != nil {
138+
t.Fatal(err)
139+
}
140+
if match != nil && match.URI == n.URI {
141+
t.Errorf("findSimilarNode returned the retracted node (sim=%.3f); merging into it would resurrect it", sim)
142+
}
143+
}
144+
145+
// TestNoResurrection_ExtractMemoriesDoesNotMutateRetracted exercises the
146+
// full extractMemories path. With a retracted node already in the DB, an
147+
// LLM-extracted candidate that's semantically close must NOT mutate the
148+
// retracted row — not its content, not its metadata, not its tombstone state.
149+
//
150+
// Pre-fix this test fails: findSimilarNode returns the retracted node, the
151+
// extractor merges new L0/L1/L2 into it via UpsertNode → UpdateNode, and the
152+
// retracted row's content is silently overwritten while its tombstone stays.
153+
func TestNoResurrection_ExtractMemoriesDoesNotMutateRetracted(t *testing.T) {
154+
db := testDB(t)
155+
ctx := context.Background()
156+
157+
n := &store.MemNode{
158+
URI: "mem://user/preferences/minimal-deps", NodeType: "leaf", Category: "preferences",
159+
L0Abstract: "Prefers minimal dependencies, standard library where possible",
160+
L1Overview: "ORIGINAL body content with enough length to pass validation thresholds.",
161+
}
162+
if err := db.CreateNode(n); err != nil {
163+
t.Fatal(err)
164+
}
165+
embedder, err := NewTFIDFEmbedder(db, 512)
166+
if err != nil {
167+
t.Fatalf("NewTFIDFEmbedder: %v", err)
168+
}
169+
vec, err := embedder.Embed(ctx, n.L0Abstract)
170+
if err != nil {
171+
t.Fatalf("Embed: %v", err)
172+
}
173+
if err := db.SaveVector(n.ID, vec, embedder.Model()); err != nil {
174+
t.Fatalf("SaveVector: %v", err)
175+
}
176+
177+
if _, err := db.RetractNode(n.URI, "operator decided this preference was wrong", ""); err != nil {
178+
t.Fatal(err)
179+
}
180+
181+
before := snapshotNode(t, db, n.URI)
182+
183+
// LLM produces a candidate semantically similar to the retracted node.
184+
extractionResponse := `[
185+
{
186+
"category": "preferences",
187+
"uri_hint": "minimal-dependencies-preference",
188+
"l0": "Prefers minimal dependencies, standard library where possible",
189+
"l1": "RESURRECTED body content that should never reach the retracted row.",
190+
"l2": "Full details from the new extraction"
191+
}
192+
]`
193+
mock := &llm.MockClient{
194+
Response: &llm.Response{Content: extractionResponse, Provider: "mock"},
195+
}
196+
197+
transcriptPath := makeTranscript(t)
198+
if err := extractMemories(db, mock, embedder, "test-session", transcriptPath); err != nil {
199+
t.Fatalf("extractMemories: %v", err)
200+
}
201+
202+
// Full-row equality — every column on the retracted node must be unchanged.
203+
assertNoResurrection(t, db, n.URI, before)
204+
}
205+
206+
// TestNoResurrection_RememberDoesNotMutateRetracted is the equivalent guard
207+
// for the public Remember API. The dedup-against-retracted gate already blocks
208+
// matching writes, but the URI-collision branch and the regular merge path
209+
// could in principle mutate a retracted row. This test pins both paths.
210+
func TestNoResurrection_RememberDoesNotMutateRetracted(t *testing.T) {
211+
db := testDB(t)
212+
mock := &llm.MockClient{Response: &llm.Response{Content: "[]"}}
213+
eng := New(db, mock)
214+
ctx := context.Background()
215+
216+
uri := seedAndEmbed(t, eng, "preferences", "doomed-pref",
217+
"original preference content for testing the no-resurrection guard",
218+
"ORIGINAL body content with enough length to pass validation thresholds.")
219+
embedder, err := NewTFIDFEmbedder(db, 512)
220+
if err != nil {
221+
t.Fatalf("NewTFIDFEmbedder: %v", err)
222+
}
223+
eng.SetEmbedder(embedder)
224+
n, err := db.GetNodeByURI(uri)
225+
if err != nil {
226+
t.Fatalf("GetNodeByURI: %v", err)
227+
}
228+
if err := eng.EmbedNode(ctx, n); err != nil {
229+
t.Fatal(err)
230+
}
231+
if _, err := db.RetractNode(uri, "test retraction", ""); err != nil {
232+
t.Fatal(err)
233+
}
234+
235+
before := snapshotNode(t, db, uri)
236+
237+
// Path 1: write to the same URI — must error, must NOT mutate.
238+
_, _, err = eng.Remember(ctx, RememberInput{
239+
Category: "preferences", Name: "doomed-pref",
240+
Summary: "different summary text but same URI to test collision",
241+
Body: "Different body content with enough length to pass validation thresholds.",
242+
AcknowledgeRetracted: true, // even with override, URI-collision path must hold
243+
})
244+
if err == nil {
245+
t.Error("Remember at retracted URI should error, did not")
246+
}
247+
assertNoResurrection(t, db, uri, before)
248+
249+
// Path 2: write to a different URI with similar content — must trigger
250+
// the dedup-against-retracted gate (no row mutation possible since the gate
251+
// fires before any write).
252+
_, _, err = eng.Remember(ctx, RememberInput{
253+
Category: "preferences", Name: "different-slug",
254+
Summary: "original preference content for testing the no-resurrection guard",
255+
Body: "Different body content with enough length to pass validation thresholds.",
256+
})
257+
if err == nil {
258+
t.Error("Remember with similar content should hit dedup-against-retracted gate, did not")
259+
}
260+
assertNoResurrection(t, db, uri, before)
261+
}

internal/server/context.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,14 @@ func (s *Server) buildContext(currentSessionID string) string {
6161
}
6262
}
6363

64-
// Relational profile (Working With You) — capped portion of budget
64+
// Relational profile (Working With You) — capped portion of budget.
65+
// A retracted profile must not be injected: silently injecting retracted
66+
// content into every future session would defeat the retraction. No
67+
// fallback path fills this section if the profile is retracted; the
68+
// session simply lacks a "Working With You" block until the profile is
69+
// re-synthesized by a future extraction.
6570
relProfile, err := s.db.GetNodeByURI("mem://user/profile/communication")
66-
if err == nil && relProfile != nil && relProfile.L1Overview != "" {
71+
if err == nil && relProfile != nil && !relProfile.IsRetracted() && relProfile.L1Overview != "" {
6772
section := "\n### Working With You\n"
6873
content := relProfile.L1Overview
6974
if len(content) > maxRelationalContext {

0 commit comments

Comments
 (0)