Skip to content

Commit 407b067

Browse files
committed
Improve semantic memory reinforcement and contradiction recall
1 parent bbf89ef commit 407b067

6 files changed

Lines changed: 460 additions & 73 deletions

File tree

docs/memory.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Accumulated facts with contradiction detection and temporal validity:
2626
- User preferences ("prefers small PRs, conventional commits")
2727
- Team context ("@sarah is the main reviewer, she cares about test coverage")
2828

29-
When a new fact contradicts an existing one, the old fact is marked superseded.
29+
When a new fact repeats an existing belief, Phantom reinforces the current fact instead of storing a near-duplicate. When a new fact contradicts an existing one, the old fact is marked superseded and the contradiction can be surfaced during later retrieval.
3030

3131
### Tier 3: Procedural Memory
3232

src/memory/__tests__/context-builder.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,37 @@ describe("MemoryContextBuilder", () => {
8888
expect(result).toContain("[confidence: 0.9]");
8989
});
9090

91+
test("includes semantic reinforcement and contradiction context", async () => {
92+
const memory = createMockMemorySystem({
93+
facts: Promise.resolve([
94+
{
95+
id: "f1",
96+
subject: "staging",
97+
predicate: "runs on",
98+
object: "port 3001",
99+
natural_language: "The staging server runs on port 3001",
100+
source_episode_ids: [],
101+
confidence: 0.9,
102+
valid_from: new Date().toISOString(),
103+
valid_until: null,
104+
version: 2,
105+
reinforcement_count: 2,
106+
contradiction_note: "The staging server runs on port 3000",
107+
previous_version_id: "f0",
108+
superseded_by_fact_id: null,
109+
category: "domain_knowledge" as const,
110+
tags: [],
111+
},
112+
]),
113+
});
114+
115+
const builder = new MemoryContextBuilder(memory, TEST_CONFIG);
116+
const result = await builder.build("staging");
117+
118+
expect(result).toContain("repeated: 3x");
119+
expect(result).toContain("Recent contradictions: The staging server runs on port 3000");
120+
});
121+
91122
test("formats episodes section correctly", async () => {
92123
const memory = createMockMemorySystem({
93124
episodes: Promise.resolve([
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { afterAll, describe, expect, mock, test } from "bun:test";
2+
import type { MemoryConfig } from "../../config/types.ts";
3+
import { EmbeddingClient } from "../embeddings.ts";
4+
import { QdrantClient } from "../qdrant-client.ts";
5+
import { SemanticStore } from "../semantic.ts";
6+
import type { SemanticFact } from "../types.ts";
7+
8+
const TEST_CONFIG: MemoryConfig = {
9+
qdrant: { url: "http://localhost:6333" },
10+
ollama: { url: "http://localhost:11434", model: "nomic-embed-text" },
11+
collections: { episodes: "episodes", semantic_facts: "semantic_facts", procedures: "procedures" },
12+
embedding: { dimensions: 768, batch_size: 32 },
13+
context: { max_tokens: 50000, episode_limit: 10, fact_limit: 20, procedure_limit: 5 },
14+
};
15+
16+
function makeTestFact(overrides?: Partial<SemanticFact>): SemanticFact {
17+
return {
18+
id: "fact-001",
19+
subject: "staging server",
20+
predicate: "runs on",
21+
object: "port 3001",
22+
natural_language: "The staging server runs on port 3001",
23+
source_episode_ids: ["ep-001"],
24+
confidence: 0.85,
25+
valid_from: new Date().toISOString(),
26+
valid_until: null,
27+
version: 1,
28+
previous_version_id: null,
29+
category: "domain_knowledge",
30+
tags: ["infra"],
31+
...overrides,
32+
};
33+
}
34+
35+
function make768dVector(): number[] {
36+
return Array.from({ length: 768 }, (_, i) => Math.cos(i * 0.01));
37+
}
38+
39+
describe("SemanticStore reconciliation", () => {
40+
const originalFetch = globalThis.fetch;
41+
42+
afterAll(() => {
43+
globalThis.fetch = originalFetch;
44+
});
45+
46+
test("store() reinforces repeated facts instead of creating duplicates", async () => {
47+
const vec = make768dVector();
48+
let upsertBody: Record<string, unknown> | null = null;
49+
50+
globalThis.fetch = mock((url: string | Request, init?: RequestInit) => {
51+
const urlStr = typeof url === "string" ? url : url.url;
52+
53+
if (urlStr.includes("/api/embed")) {
54+
return Promise.resolve(new Response(JSON.stringify({ embeddings: [vec] }), { status: 200 }));
55+
}
56+
57+
if (urlStr.includes("/points/query")) {
58+
return Promise.resolve(
59+
new Response(
60+
JSON.stringify({
61+
result: {
62+
points: [
63+
{
64+
id: "fact-existing",
65+
score: 0.94,
66+
payload: {
67+
subject: "staging server",
68+
predicate: "runs on",
69+
object: "port 3001",
70+
natural_language: "The staging server runs on port 3001",
71+
source_episode_ids: ["ep-001"],
72+
confidence: 0.75,
73+
valid_from: Date.now() - 86400000,
74+
valid_until: null,
75+
version: 2,
76+
reinforcement_count: 1,
77+
last_reinforced_at: Date.now() - 86400000,
78+
category: "domain_knowledge",
79+
tags: ["infra"],
80+
},
81+
},
82+
],
83+
},
84+
}),
85+
{ status: 200, headers: { "Content-Type": "application/json" } },
86+
),
87+
);
88+
}
89+
90+
if (urlStr.includes("/points") && init?.method === "PUT") {
91+
upsertBody = JSON.parse(init.body as string);
92+
}
93+
94+
return Promise.resolve(new Response(JSON.stringify({ status: "ok" }), { status: 200 }));
95+
}) as unknown as typeof fetch;
96+
97+
const store = new SemanticStore(new QdrantClient(TEST_CONFIG), new EmbeddingClient(TEST_CONFIG), TEST_CONFIG);
98+
const id = await store.store(makeTestFact({ source_episode_ids: ["ep-002"], tags: ["deploy"] }));
99+
100+
expect(id).toBe("fact-existing");
101+
expect(upsertBody).not.toBeNull();
102+
103+
const upsertData = upsertBody as unknown as { points: Array<Record<string, unknown>> };
104+
const point = upsertData.points[0] as Record<string, unknown>;
105+
const payload = point.payload as Record<string, unknown>;
106+
expect(point.id).toBe("fact-existing");
107+
expect(payload.reinforcement_count).toBe(2);
108+
expect(payload.version).toBe(3);
109+
expect(payload.confidence).toBe(0.9);
110+
expect(payload.source_episode_ids).toEqual(["ep-001", "ep-002"]);
111+
expect(payload.tags).toEqual(["infra", "deploy"]);
112+
});
113+
114+
test("store() immediately supersedes lower-confidence contradictions", async () => {
115+
const vec = make768dVector();
116+
let upsertBody: Record<string, unknown> | null = null;
117+
118+
globalThis.fetch = mock((url: string | Request, init?: RequestInit) => {
119+
const urlStr = typeof url === "string" ? url : url.url;
120+
121+
if (urlStr.includes("/api/embed")) {
122+
return Promise.resolve(new Response(JSON.stringify({ embeddings: [vec] }), { status: 200 }));
123+
}
124+
125+
if (urlStr.includes("/points/query")) {
126+
return Promise.resolve(
127+
new Response(
128+
JSON.stringify({
129+
result: {
130+
points: [
131+
{
132+
id: "fact-current",
133+
score: 0.93,
134+
payload: {
135+
subject: "staging server",
136+
predicate: "runs on",
137+
object: "port 3000",
138+
natural_language: "The staging server runs on port 3000",
139+
confidence: 0.95,
140+
valid_from: Date.now() - 86400000,
141+
valid_until: null,
142+
version: 4,
143+
category: "domain_knowledge",
144+
tags: ["infra"],
145+
},
146+
},
147+
],
148+
},
149+
}),
150+
{ status: 200, headers: { "Content-Type": "application/json" } },
151+
),
152+
);
153+
}
154+
155+
if (urlStr.includes("/points") && init?.method === "PUT") {
156+
upsertBody = JSON.parse(init.body as string);
157+
}
158+
159+
return Promise.resolve(new Response(JSON.stringify({ status: "ok" }), { status: 200 }));
160+
}) as unknown as typeof fetch;
161+
162+
const store = new SemanticStore(new QdrantClient(TEST_CONFIG), new EmbeddingClient(TEST_CONFIG), TEST_CONFIG);
163+
await store.store(makeTestFact({ object: "port 3001", confidence: 0.6 }));
164+
165+
const upsertData = upsertBody as unknown as { points: Array<Record<string, unknown>> };
166+
const point = upsertData.points[0] as Record<string, unknown>;
167+
const payload = point.payload as Record<string, unknown>;
168+
expect(payload.valid_until).toBeDefined();
169+
expect(payload.previous_version_id).toBe("fact-current");
170+
expect(payload.superseded_by_fact_id).toBe("fact-current");
171+
});
172+
173+
test("recall() attaches contradiction notes to current facts", async () => {
174+
const vec = make768dVector();
175+
let queryCount = 0;
176+
177+
globalThis.fetch = mock((url: string | Request) => {
178+
const urlStr = typeof url === "string" ? url : url.url;
179+
180+
if (urlStr.includes("/api/embed")) {
181+
return Promise.resolve(new Response(JSON.stringify({ embeddings: [vec] }), { status: 200 }));
182+
}
183+
184+
if (urlStr.includes("/points/query")) {
185+
queryCount += 1;
186+
187+
if (queryCount === 1) {
188+
return Promise.resolve(
189+
new Response(
190+
JSON.stringify({
191+
result: {
192+
points: [
193+
{
194+
id: "fact-current",
195+
score: 0.92,
196+
payload: {
197+
subject: "staging server",
198+
predicate: "runs on",
199+
object: "port 3001",
200+
natural_language: "The staging server runs on port 3001",
201+
confidence: 0.9,
202+
valid_from: Date.now() - 86400000,
203+
valid_until: null,
204+
version: 2,
205+
reinforcement_count: 1,
206+
category: "domain_knowledge",
207+
tags: ["infra"],
208+
},
209+
},
210+
],
211+
},
212+
}),
213+
{ status: 200, headers: { "Content-Type": "application/json" } },
214+
),
215+
);
216+
}
217+
218+
return Promise.resolve(
219+
new Response(
220+
JSON.stringify({
221+
result: {
222+
points: [
223+
{
224+
id: "fact-old",
225+
score: 0.88,
226+
payload: {
227+
subject: "staging server",
228+
predicate: "runs on",
229+
object: "port 3000",
230+
natural_language: "The staging server runs on port 3000",
231+
confidence: 0.8,
232+
valid_from: Date.now() - 2 * 86400000,
233+
valid_until: Date.now() - 86400000,
234+
version: 1,
235+
superseded_by_fact_id: "fact-current",
236+
category: "domain_knowledge",
237+
tags: ["infra"],
238+
},
239+
},
240+
],
241+
},
242+
}),
243+
{ status: 200, headers: { "Content-Type": "application/json" } },
244+
),
245+
);
246+
}
247+
248+
return Promise.resolve(new Response(JSON.stringify({ status: "ok" }), { status: 200 }));
249+
}) as unknown as typeof fetch;
250+
251+
const store = new SemanticStore(new QdrantClient(TEST_CONFIG), new EmbeddingClient(TEST_CONFIG), TEST_CONFIG);
252+
const facts = await store.recall("staging server");
253+
254+
expect(facts).toHaveLength(1);
255+
expect(facts[0].contradiction_note).toContain("port 3000");
256+
});
257+
});

src/memory/context-builder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ export class MemoryContextBuilder {
6969
}
7070

7171
private formatFacts(facts: SemanticFact[]): string {
72-
const lines = facts.map((f) => `- ${f.natural_language} [confidence: ${f.confidence.toFixed(1)}]`);
72+
const lines = facts.map((f) => {
73+
const metadata = [`confidence: ${f.confidence.toFixed(1)}`];
74+
const repetitionCount = (f.reinforcement_count ?? 0) + 1;
75+
if (repetitionCount > 1) metadata.push(`repeated: ${repetitionCount}x`);
76+
const contradictionNote = f.contradiction_note ? ` Recent contradictions: ${f.contradiction_note}` : "";
77+
return `- ${f.natural_language} [${metadata.join(", ")}]${contradictionNote}`;
78+
});
7379
return `## Known Facts\n${lines.join("\n")}`;
7480
}
7581

0 commit comments

Comments
 (0)