From 407b0675472d501820ef5055ec263ec8433ee17b Mon Sep 17 00:00:00 2001 From: coe0718 Date: Mon, 30 Mar 2026 20:54:43 -0400 Subject: [PATCH] Improve semantic memory reinforcement and contradiction recall --- docs/memory.md | 2 +- src/memory/__tests__/context-builder.test.ts | 31 +++ .../__tests__/semantic-reconciliation.test.ts | 257 ++++++++++++++++++ src/memory/context-builder.ts | 8 +- src/memory/semantic.ts | 230 +++++++++++----- src/memory/types.ts | 5 + 6 files changed, 460 insertions(+), 73 deletions(-) create mode 100644 src/memory/__tests__/semantic-reconciliation.test.ts diff --git a/docs/memory.md b/docs/memory.md index 70e42e8..ca650b4 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -26,7 +26,7 @@ Accumulated facts with contradiction detection and temporal validity: - User preferences ("prefers small PRs, conventional commits") - Team context ("@sarah is the main reviewer, she cares about test coverage") -When a new fact contradicts an existing one, the old fact is marked superseded. +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. ### Tier 3: Procedural Memory diff --git a/src/memory/__tests__/context-builder.test.ts b/src/memory/__tests__/context-builder.test.ts index 13300c1..99c5ad0 100644 --- a/src/memory/__tests__/context-builder.test.ts +++ b/src/memory/__tests__/context-builder.test.ts @@ -88,6 +88,37 @@ describe("MemoryContextBuilder", () => { expect(result).toContain("[confidence: 0.9]"); }); + test("includes semantic reinforcement and contradiction context", async () => { + const memory = createMockMemorySystem({ + facts: Promise.resolve([ + { + id: "f1", + subject: "staging", + predicate: "runs on", + object: "port 3001", + natural_language: "The staging server runs on port 3001", + source_episode_ids: [], + confidence: 0.9, + valid_from: new Date().toISOString(), + valid_until: null, + version: 2, + reinforcement_count: 2, + contradiction_note: "The staging server runs on port 3000", + previous_version_id: "f0", + superseded_by_fact_id: null, + category: "domain_knowledge" as const, + tags: [], + }, + ]), + }); + + const builder = new MemoryContextBuilder(memory, TEST_CONFIG); + const result = await builder.build("staging"); + + expect(result).toContain("repeated: 3x"); + expect(result).toContain("Recent contradictions: The staging server runs on port 3000"); + }); + test("formats episodes section correctly", async () => { const memory = createMockMemorySystem({ episodes: Promise.resolve([ diff --git a/src/memory/__tests__/semantic-reconciliation.test.ts b/src/memory/__tests__/semantic-reconciliation.test.ts new file mode 100644 index 0000000..58cd96b --- /dev/null +++ b/src/memory/__tests__/semantic-reconciliation.test.ts @@ -0,0 +1,257 @@ +import { afterAll, describe, expect, mock, test } from "bun:test"; +import type { MemoryConfig } from "../../config/types.ts"; +import { EmbeddingClient } from "../embeddings.ts"; +import { QdrantClient } from "../qdrant-client.ts"; +import { SemanticStore } from "../semantic.ts"; +import type { SemanticFact } from "../types.ts"; + +const TEST_CONFIG: MemoryConfig = { + qdrant: { url: "http://localhost:6333" }, + ollama: { url: "http://localhost:11434", model: "nomic-embed-text" }, + collections: { episodes: "episodes", semantic_facts: "semantic_facts", procedures: "procedures" }, + embedding: { dimensions: 768, batch_size: 32 }, + context: { max_tokens: 50000, episode_limit: 10, fact_limit: 20, procedure_limit: 5 }, +}; + +function makeTestFact(overrides?: Partial): SemanticFact { + return { + id: "fact-001", + subject: "staging server", + predicate: "runs on", + object: "port 3001", + natural_language: "The staging server runs on port 3001", + source_episode_ids: ["ep-001"], + confidence: 0.85, + valid_from: new Date().toISOString(), + valid_until: null, + version: 1, + previous_version_id: null, + category: "domain_knowledge", + tags: ["infra"], + ...overrides, + }; +} + +function make768dVector(): number[] { + return Array.from({ length: 768 }, (_, i) => Math.cos(i * 0.01)); +} + +describe("SemanticStore reconciliation", () => { + const originalFetch = globalThis.fetch; + + afterAll(() => { + globalThis.fetch = originalFetch; + }); + + test("store() reinforces repeated facts instead of creating duplicates", async () => { + const vec = make768dVector(); + let upsertBody: Record | null = null; + + globalThis.fetch = mock((url: string | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.url; + + if (urlStr.includes("/api/embed")) { + return Promise.resolve(new Response(JSON.stringify({ embeddings: [vec] }), { status: 200 })); + } + + if (urlStr.includes("/points/query")) { + return Promise.resolve( + new Response( + JSON.stringify({ + result: { + points: [ + { + id: "fact-existing", + score: 0.94, + payload: { + subject: "staging server", + predicate: "runs on", + object: "port 3001", + natural_language: "The staging server runs on port 3001", + source_episode_ids: ["ep-001"], + confidence: 0.75, + valid_from: Date.now() - 86400000, + valid_until: null, + version: 2, + reinforcement_count: 1, + last_reinforced_at: Date.now() - 86400000, + category: "domain_knowledge", + tags: ["infra"], + }, + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + } + + if (urlStr.includes("/points") && init?.method === "PUT") { + upsertBody = JSON.parse(init.body as string); + } + + return Promise.resolve(new Response(JSON.stringify({ status: "ok" }), { status: 200 })); + }) as unknown as typeof fetch; + + const store = new SemanticStore(new QdrantClient(TEST_CONFIG), new EmbeddingClient(TEST_CONFIG), TEST_CONFIG); + const id = await store.store(makeTestFact({ source_episode_ids: ["ep-002"], tags: ["deploy"] })); + + expect(id).toBe("fact-existing"); + expect(upsertBody).not.toBeNull(); + + const upsertData = upsertBody as unknown as { points: Array> }; + const point = upsertData.points[0] as Record; + const payload = point.payload as Record; + expect(point.id).toBe("fact-existing"); + expect(payload.reinforcement_count).toBe(2); + expect(payload.version).toBe(3); + expect(payload.confidence).toBe(0.9); + expect(payload.source_episode_ids).toEqual(["ep-001", "ep-002"]); + expect(payload.tags).toEqual(["infra", "deploy"]); + }); + + test("store() immediately supersedes lower-confidence contradictions", async () => { + const vec = make768dVector(); + let upsertBody: Record | null = null; + + globalThis.fetch = mock((url: string | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.url; + + if (urlStr.includes("/api/embed")) { + return Promise.resolve(new Response(JSON.stringify({ embeddings: [vec] }), { status: 200 })); + } + + if (urlStr.includes("/points/query")) { + return Promise.resolve( + new Response( + JSON.stringify({ + result: { + points: [ + { + id: "fact-current", + score: 0.93, + payload: { + subject: "staging server", + predicate: "runs on", + object: "port 3000", + natural_language: "The staging server runs on port 3000", + confidence: 0.95, + valid_from: Date.now() - 86400000, + valid_until: null, + version: 4, + category: "domain_knowledge", + tags: ["infra"], + }, + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + } + + if (urlStr.includes("/points") && init?.method === "PUT") { + upsertBody = JSON.parse(init.body as string); + } + + return Promise.resolve(new Response(JSON.stringify({ status: "ok" }), { status: 200 })); + }) as unknown as typeof fetch; + + const store = new SemanticStore(new QdrantClient(TEST_CONFIG), new EmbeddingClient(TEST_CONFIG), TEST_CONFIG); + await store.store(makeTestFact({ object: "port 3001", confidence: 0.6 })); + + const upsertData = upsertBody as unknown as { points: Array> }; + const point = upsertData.points[0] as Record; + const payload = point.payload as Record; + expect(payload.valid_until).toBeDefined(); + expect(payload.previous_version_id).toBe("fact-current"); + expect(payload.superseded_by_fact_id).toBe("fact-current"); + }); + + test("recall() attaches contradiction notes to current facts", async () => { + const vec = make768dVector(); + let queryCount = 0; + + globalThis.fetch = mock((url: string | Request) => { + const urlStr = typeof url === "string" ? url : url.url; + + if (urlStr.includes("/api/embed")) { + return Promise.resolve(new Response(JSON.stringify({ embeddings: [vec] }), { status: 200 })); + } + + if (urlStr.includes("/points/query")) { + queryCount += 1; + + if (queryCount === 1) { + return Promise.resolve( + new Response( + JSON.stringify({ + result: { + points: [ + { + id: "fact-current", + score: 0.92, + payload: { + subject: "staging server", + predicate: "runs on", + object: "port 3001", + natural_language: "The staging server runs on port 3001", + confidence: 0.9, + valid_from: Date.now() - 86400000, + valid_until: null, + version: 2, + reinforcement_count: 1, + category: "domain_knowledge", + tags: ["infra"], + }, + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + result: { + points: [ + { + id: "fact-old", + score: 0.88, + payload: { + subject: "staging server", + predicate: "runs on", + object: "port 3000", + natural_language: "The staging server runs on port 3000", + confidence: 0.8, + valid_from: Date.now() - 2 * 86400000, + valid_until: Date.now() - 86400000, + version: 1, + superseded_by_fact_id: "fact-current", + category: "domain_knowledge", + tags: ["infra"], + }, + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + } + + return Promise.resolve(new Response(JSON.stringify({ status: "ok" }), { status: 200 })); + }) as unknown as typeof fetch; + + const store = new SemanticStore(new QdrantClient(TEST_CONFIG), new EmbeddingClient(TEST_CONFIG), TEST_CONFIG); + const facts = await store.recall("staging server"); + + expect(facts).toHaveLength(1); + expect(facts[0].contradiction_note).toContain("port 3000"); + }); +}); diff --git a/src/memory/context-builder.ts b/src/memory/context-builder.ts index 00f5381..433f225 100644 --- a/src/memory/context-builder.ts +++ b/src/memory/context-builder.ts @@ -69,7 +69,13 @@ export class MemoryContextBuilder { } private formatFacts(facts: SemanticFact[]): string { - const lines = facts.map((f) => `- ${f.natural_language} [confidence: ${f.confidence.toFixed(1)}]`); + const lines = facts.map((f) => { + const metadata = [`confidence: ${f.confidence.toFixed(1)}`]; + const repetitionCount = (f.reinforcement_count ?? 0) + 1; + if (repetitionCount > 1) metadata.push(`repeated: ${repetitionCount}x`); + const contradictionNote = f.contradiction_note ? ` Recent contradictions: ${f.contradiction_note}` : ""; + return `- ${f.natural_language} [${metadata.join(", ")}]${contradictionNote}`; + }); return `## Known Facts\n${lines.join("\n")}`; } diff --git a/src/memory/semantic.ts b/src/memory/semantic.ts index 619d8e4..0b0e0d1 100644 --- a/src/memory/semantic.ts +++ b/src/memory/semantic.ts @@ -24,6 +24,7 @@ const PAYLOAD_INDEXES: { field: string; type: "keyword" | "integer" | "float" }[ ]; const SIMILARITY_THRESHOLD = 0.85; +const CONTRADICTION_NOTE_LIMIT = 3; export class SemanticStore { private qdrant: QdrantClient; @@ -48,89 +49,53 @@ export class SemanticStore { } async store(fact: SemanticFact): Promise { - // Check for contradictions before storing - const contradictions = await this.findContradictions(fact); + const candidates = await this.findCurrentCandidates(fact); + const reinforcementTarget = candidates.find((candidate) => isSameFact(candidate, fact)); + if (reinforcementTarget) { + return this.reinforceFact(reinforcementTarget, fact); + } + + const contradictions = candidates.filter((candidate) => !isSameFact(candidate, fact)); + const strongerContradiction = contradictions.find((candidate) => candidate.confidence > fact.confidence); + + if (strongerContradiction) { + const rejectedFact: SemanticFact = { + ...fact, + valid_until: fact.valid_from, + previous_version_id: strongerContradiction.id, + superseded_by_fact_id: strongerContradiction.id, + }; + await this.upsertFact(rejectedFact); + return rejectedFact.id; + } for (const existing of contradictions) { await this.resolveContradiction(fact, existing); } - const factVec = await this.embedder.embed(fact.natural_language); - const sparse = textToSparseVector(`${fact.subject} ${fact.predicate} ${fact.object} ${fact.natural_language}`); - - await this.qdrant.upsert(this.collectionName, [ - { - id: fact.id, - vector: { - fact: factVec, - text_bm25: sparse, - }, - payload: { - subject: fact.subject, - predicate: fact.predicate, - object: fact.object, - natural_language: fact.natural_language, - source_episode_ids: fact.source_episode_ids, - confidence: fact.confidence, - valid_from: new Date(fact.valid_from).getTime(), - valid_until: fact.valid_until ? new Date(fact.valid_until).getTime() : null, - version: fact.version, - previous_version_id: fact.previous_version_id, - category: fact.category, - tags: fact.tags, - }, - }, - ]); - + await this.upsertFact({ + ...fact, + previous_version_id: fact.previous_version_id ?? contradictions[0]?.id ?? null, + }); return fact.id; } async recall(query: string, options?: RecallOptions): Promise { - const limit = options?.limit ?? 20; + const facts = await this.searchFacts(query, options); + if ((options?.validity ?? "current") !== "current" || facts.length === 0) return facts; - const queryVec = await this.embedder.embed(query); - const sparse = textToSparseVector(query); - - // Default: only return currently-valid facts - const filter = this.buildFilter(options); - - const results = await this.qdrant.search(this.collectionName, { - denseVector: queryVec, - denseVectorName: "fact", - sparseVector: sparse, - sparseVectorName: "text_bm25", - filter, - limit, - withPayload: true, + const superseded = await this.searchFacts(query, { + ...options, + validity: "superseded", + limit: Math.min(Math.max(options?.limit ?? 20, CONTRADICTION_NOTE_LIMIT), 10), }); - const minScore = options?.minScore ?? 0; - return results.filter((r) => r.score >= minScore).map((r) => this.payloadToFact(r)); + return this.attachContradictionNotes(facts, superseded); } async findContradictions(newFact: SemanticFact): Promise { - // Search for facts with the same subject and predicate - const queryText = `${newFact.subject} ${newFact.predicate}`; - const queryVec = await this.embedder.embed(queryText); - - const results = await this.qdrant.search(this.collectionName, { - denseVector: queryVec, - denseVectorName: "fact", - filter: { - must: [{ key: "subject", match: { value: newFact.subject } }, { is_null: { key: "valid_until" } }], - }, - limit: 10, - withPayload: true, - }); - - return results - .filter((r) => { - if (r.id === newFact.id) return false; - if (r.score < SIMILARITY_THRESHOLD) return false; - const existingObject = r.payload.object as string; - return existingObject !== newFact.object; - }) - .map((r) => this.payloadToFact(r)); + const candidates = await this.findCurrentCandidates(newFact); + return candidates.filter((candidate) => !isSameFact(candidate, newFact)); } async resolveContradiction(newFact: SemanticFact, existingFact: SemanticFact): Promise { @@ -138,21 +103,24 @@ export class SemanticStore { if (newFact.confidence >= existingFact.confidence) { await this.qdrant.updatePayload(this.collectionName, existingFact.id, { valid_until: new Date(newFact.valid_from).getTime(), + superseded_by_fact_id: newFact.id, }); } } private buildFilter(options?: RecallOptions): Record | undefined { const must: Record[] = []; + const validity = options?.validity ?? "current"; - // Default: only currently-valid facts - if (!options?.timeRange) { + if (validity === "current") { must.push({ is_null: { key: "valid_until" } }); + } else if (validity === "superseded") { + must.push({ key: "valid_until", range: { gte: 0 } }); } if (options?.timeRange) { must.push({ - key: "valid_from", + key: validity === "superseded" ? "valid_until" : "valid_from", range: { gte: options.timeRange.from.getTime(), lte: options.timeRange.to.getTime(), @@ -174,6 +142,111 @@ export class SemanticStore { return { must }; } + private async findCurrentCandidates(fact: SemanticFact): Promise { + const queryText = `${fact.subject} ${fact.predicate} ${fact.object}`; + const queryVec = await this.embedder.embed(queryText); + const results = await this.qdrant.search(this.collectionName, { + denseVector: queryVec, + denseVectorName: "fact", + filter: { + must: [ + { key: "subject", match: { value: fact.subject } }, + { key: "predicate", match: { value: fact.predicate } }, + { is_null: { key: "valid_until" } }, + ], + }, + limit: 10, + withPayload: true, + }); + + return results + .filter((result) => result.id !== fact.id && result.score >= SIMILARITY_THRESHOLD) + .map((result) => this.payloadToFact(result)); + } + + private async reinforceFact(existingFact: SemanticFact, newFact: SemanticFact): Promise { + const mergedFact: SemanticFact = { + ...existingFact, + natural_language: + newFact.natural_language.length >= existingFact.natural_language.length + ? newFact.natural_language + : existingFact.natural_language, + source_episode_ids: uniqueStrings([...existingFact.source_episode_ids, ...newFact.source_episode_ids]), + confidence: Math.min(1, Math.max(existingFact.confidence, newFact.confidence) + 0.05), + version: existingFact.version + 1, + reinforcement_count: (existingFact.reinforcement_count ?? 0) + 1, + last_reinforced_at: newFact.valid_from, + tags: uniqueStrings([...existingFact.tags, ...newFact.tags]), + }; + + await this.upsertFact(mergedFact); + return mergedFact.id; + } + + private async searchFacts(query: string, options?: RecallOptions): Promise { + const queryVec = await this.embedder.embed(query); + const sparse = textToSparseVector(query); + const results = await this.qdrant.search(this.collectionName, { + denseVector: queryVec, + denseVectorName: "fact", + sparseVector: sparse, + sparseVectorName: "text_bm25", + filter: this.buildFilter(options), + limit: options?.limit ?? 20, + withPayload: true, + }); + + const minScore = options?.minScore ?? 0; + return results.filter((result) => result.score >= minScore).map((result) => this.payloadToFact(result)); + } + + private attachContradictionNotes(currentFacts: SemanticFact[], supersededFacts: SemanticFact[]): SemanticFact[] { + const notesByFactId = new Map(); + + for (const fact of supersededFacts) { + if (!fact.superseded_by_fact_id) continue; + const existing = notesByFactId.get(fact.superseded_by_fact_id) ?? []; + if (existing.length < CONTRADICTION_NOTE_LIMIT) { + existing.push(fact.natural_language); + notesByFactId.set(fact.superseded_by_fact_id, existing); + } + } + + return currentFacts.map((fact) => ({ + ...fact, + contradiction_note: notesByFactId.get(fact.id)?.join(" | ") ?? null, + })); + } + + private async upsertFact(fact: SemanticFact): Promise { + const factVec = await this.embedder.embed(fact.natural_language); + const sparse = textToSparseVector(`${fact.subject} ${fact.predicate} ${fact.object} ${fact.natural_language}`); + + await this.qdrant.upsert(this.collectionName, [ + { + id: fact.id, + vector: { fact: factVec, text_bm25: sparse }, + payload: { + subject: fact.subject, + predicate: fact.predicate, + object: fact.object, + natural_language: fact.natural_language, + source_episode_ids: fact.source_episode_ids, + confidence: fact.confidence, + valid_from: new Date(fact.valid_from).getTime(), + valid_until: fact.valid_until ? new Date(fact.valid_until).getTime() : null, + version: fact.version, + previous_version_id: fact.previous_version_id, + reinforcement_count: fact.reinforcement_count ?? 0, + last_reinforced_at: fact.last_reinforced_at ? new Date(fact.last_reinforced_at).getTime() : null, + superseded_by_fact_id: fact.superseded_by_fact_id ?? null, + category: fact.category, + tags: fact.tags, + }, + }, + ]); + } + private payloadToFact(result: QdrantSearchResult): SemanticFact { const p = result.payload; return { @@ -188,8 +261,23 @@ export class SemanticStore { valid_until: p.valid_until ? new Date(p.valid_until as number).toISOString() : null, version: (p.version as number) ?? 1, previous_version_id: (p.previous_version_id as string | null) ?? null, + reinforcement_count: (p.reinforcement_count as number) ?? 0, + last_reinforced_at: p.last_reinforced_at ? new Date(p.last_reinforced_at as number).toISOString() : null, + superseded_by_fact_id: (p.superseded_by_fact_id as string | null) ?? null, category: (p.category as SemanticFact["category"]) ?? "domain_knowledge", tags: (p.tags as string[]) ?? [], }; } } + +function isSameFact(existingFact: SemanticFact, newFact: SemanticFact): boolean { + return normalizeFactValue(existingFact.object) === normalizeFactValue(newFact.object); +} + +function normalizeFactValue(value: string): string { + return value.trim().toLowerCase(); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/memory/types.ts b/src/memory/types.ts index 8e80072..c4e7af4 100644 --- a/src/memory/types.ts +++ b/src/memory/types.ts @@ -37,6 +37,10 @@ export type SemanticFact = { valid_until: string | null; version: number; previous_version_id: string | null; + reinforcement_count?: number; + last_reinforced_at?: string | null; + superseded_by_fact_id?: string | null; + contradiction_note?: string | null; category: FactCategory; tags: string[]; }; @@ -71,6 +75,7 @@ export type RecallOptions = { limit?: number; minScore?: number; strategy?: "recency" | "similarity" | "temporal" | "metadata"; + validity?: "current" | "superseded" | "all"; timeRange?: { from: Date; to: Date }; filters?: Record; };