fix: rank receipt-backed memory first

This commit is contained in:
artale 2026-06-26 22:57:17 +02:00
parent 975ef28b15
commit 3301a6be1e
2 changed files with 98 additions and 7 deletions

View File

@ -0,0 +1,58 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vitest";
import { StateStore } from "../core/state-store.js";
import { PersistentMemory } from "./persistent-memory.js";
function tmpStore() {
return new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), "fable-memory-")));
}
describe("persistent memory provenance", () => {
it("ranks receipt-backed verified memories before stale unverified memory", () => {
const memory = new PersistentMemory(tmpStore());
memory.recordEpisode({
sessionId: "old",
task: "factory containers",
summary: "stale memory says 25 containers",
iterations: 1,
finalQuality: 0.5,
keyEvents: [],
tags: ["factory"],
observed_at: "2026-06-18T00:00:00.000Z",
verification: "unverified",
});
memory.recordEpisode({
sessionId: "new",
task: "factory containers",
summary: "live receipt says 16 containers",
iterations: 1,
finalQuality: 1,
keyEvents: [],
tags: ["factory"],
observed_at: "2026-06-26T00:00:00.000Z",
verification: "verified",
receipt_id: "verify-cycle:live",
});
const [first] = memory.queryEpisodic({ tags: ["factory"] });
expect(first.summary).toContain("16 containers");
expect(first.receipt_id).toBe("verify-cycle:live");
});
it("filters superseded memories from recall", () => {
const memory = new PersistentMemory(tmpStore());
memory.recordEpisode({
sessionId: "old",
task: "deploy route",
summary: "8098 is deploy authority",
iterations: 1,
finalQuality: 0.1,
keyEvents: [],
tags: ["deploy"],
status: "superseded",
});
expect(memory.queryEpisodic({ tags: ["deploy"] })).toEqual([]);
});
});

View File

@ -17,7 +17,7 @@ import type { LoopIteration, StateEntry } from "../core/types.js";
// ── Types ────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────
export interface EpisodicMemory { export interface EpisodicMemory extends MemoryProvenance {
/** Unique episode identifier */ /** Unique episode identifier */
episodeId: string; episodeId: string;
sessionId: string; sessionId: string;
@ -37,7 +37,16 @@ export interface MemoryEvent {
importance: number; // 0-1, for retrieval weighting importance: number; // 0-1, for retrieval weighting
} }
export interface SemanticMemory { export interface MemoryProvenance {
observed_at?: string;
source?: string;
verification?: "verified" | "unverified" | "superseded";
receipt_id?: string;
supersedes_id?: string;
status?: "active" | "superseded";
}
export interface SemanticMemory extends MemoryProvenance {
id: string; id: string;
concept: string; concept: string;
content: string; content: string;
@ -48,7 +57,7 @@ export interface SemanticMemory {
accessCount: number; accessCount: number;
} }
export interface ProceduralMemory { export interface ProceduralMemory extends MemoryProvenance {
id: string; id: string;
skillName: string; skillName: string;
steps: string[]; steps: string[];
@ -100,9 +109,13 @@ export class PersistentMemory {
/** Record an episode (a completed session or significant subtask) */ /** Record an episode (a completed session or significant subtask) */
recordEpisode(episode: Omit<EpisodicMemory, "episodeId" | "timestamp">): EpisodicMemory { recordEpisode(episode: Omit<EpisodicMemory, "episodeId" | "timestamp">): EpisodicMemory {
const timestamp = new Date().toISOString();
const full: EpisodicMemory = { const full: EpisodicMemory = {
episodeId: StateStore.uid(), episodeId: StateStore.uid(),
timestamp: new Date().toISOString(), timestamp,
observed_at: timestamp,
verification: "unverified",
status: "active",
...episode, ...episode,
}; };
this.store.write("memory/episodic", `${full.episodeId}.json`, full); this.store.write("memory/episodic", `${full.episodeId}.json`, full);
@ -189,6 +202,12 @@ export class PersistentMemory {
]; ];
existing.tags = [...new Set([...existing.tags, ...entry.tags])]; existing.tags = [...new Set([...existing.tags, ...entry.tags])];
existing.lastAccessed = new Date().toISOString(); existing.lastAccessed = new Date().toISOString();
existing.observed_at = entry.observed_at ?? existing.observed_at ?? existing.lastAccessed;
existing.source = entry.source ?? existing.source;
existing.receipt_id = entry.receipt_id ?? existing.receipt_id;
existing.supersedes_id = entry.supersedes_id ?? existing.supersedes_id;
existing.status = entry.status ?? existing.status ?? "active";
existing.verification = entry.verification ?? existing.verification ?? "unverified";
this.store.write( this.store.write(
"memory/semantic", "memory/semantic",
`${existing.id}.json`, `${existing.id}.json`,
@ -197,10 +216,14 @@ export class PersistentMemory {
return existing; return existing;
} }
const timestamp = new Date().toISOString();
const full: SemanticMemory = { const full: SemanticMemory = {
id: StateStore.uid(), id: StateStore.uid(),
lastAccessed: new Date().toISOString(), lastAccessed: timestamp,
accessCount: 1, accessCount: 1,
observed_at: timestamp,
verification: entry.verification ?? "unverified",
status: "active",
...entry, ...entry,
}; };
this.store.write("memory/semantic", `${full.id}.json`, full); this.store.write("memory/semantic", `${full.id}.json`, full);
@ -272,6 +295,9 @@ export class PersistentMemory {
recordProcedural(entry: Omit<ProceduralMemory, "id">): ProceduralMemory { recordProcedural(entry: Omit<ProceduralMemory, "id">): ProceduralMemory {
const full: ProceduralMemory = { const full: ProceduralMemory = {
id: StateStore.uid(), id: StateStore.uid(),
observed_at: new Date().toISOString(),
verification: "unverified",
status: "active",
...entry, ...entry,
}; };
this.store.write("memory/procedural", `${full.id}.json`, full); this.store.write("memory/procedural", `${full.id}.json`, full);
@ -418,8 +444,15 @@ export class PersistentMemory {
); );
} }
// Sort by recency (assuming items have timestamp or lastAccessed) filtered = filtered
filtered.sort(() => 0); // stable sort .filter((item: T & MemoryProvenance) => (item.status ?? "active") === "active")
.sort((a: T & MemoryProvenance, b: T & MemoryProvenance) => {
const receiptRank = (b.receipt_id ? 1 : 0) - (a.receipt_id ? 1 : 0);
if (receiptRank !== 0) return receiptRank;
const verifiedRank = (b.verification === "verified" ? 1 : 0) - (a.verification === "verified" ? 1 : 0);
if (verifiedRank !== 0) return verifiedRank;
return Date.parse(b.observed_at ?? "") - Date.parse(a.observed_at ?? "");
});
if (query.limit) { if (query.limit) {
filtered = filtered.slice(0, query.limit); filtered = filtered.slice(0, query.limit);