Skip to content

Commit fee4203

Browse files
Jarvis (Piotr)claude
andcommitted
Merge JME Phase 2 — consolidator + dedup/supersede + nightly batch (audited R1+R2)
Jarvis-authored core (5843f60 upsertFact/consolidate, f3db341 Option-A wiring) + operator-session audit fixes (76494d9 true-cosine dedup, nightly batch, prune units; 2f38de4 empty-response retain). R1 FAIL -> all three criticals fixed -> R2 PASS. Consolidation runs as the jme-consolidate cron (02:45 MX) - one Haiku extraction per night over the settled episodic window, cosine-dedup upsert, exact-window delete, 7d orphan sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186BhSFY9sFFHCPeFnpbvki
2 parents 6fb9f0b + 2f38de4 commit fee4203

5 files changed

Lines changed: 708 additions & 20 deletions

File tree

src/memory/jme.test.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
import { describe, it, expect, beforeEach, vi } from "vitest";
1313
import Database from "better-sqlite3";
1414

15+
// Phase 2 mocks (hoisted before dynamic import)
16+
const inferMock = vi.fn();
17+
vi.mock("../inference/adapter.js", () => ({
18+
infer: (...args: unknown[]) => inferMock(...args),
19+
}));
20+
vi.mock("../inference/claude-sdk.js", () => ({
21+
HAIKU_MODEL_ID: "claude-haiku-test",
22+
}));
23+
1524
// ── Mock DB & dependencies ───────────────────────────────────────────────────
1625

1726
// We create an in-memory DB and run the JME schema manually
@@ -286,3 +295,301 @@ describe("JME — stats", () => {
286295
expect(stats.factsWithEmbedding).toBe(0); // embed() returns null in tests
287296
});
288297
});
298+
299+
// ── Phase 2: Consolidator + Dedup ─────────────────────────────────────────────
300+
301+
describe("JME — upsertFact (dedup)", () => {
302+
it("inserts a new fact when no near-duplicate exists (embed unavailable)", async () => {
303+
const { upsertFact, jmeStats } = await getJme();
304+
305+
// embed() mock returns null → no vector comparison → plain insert
306+
const outcome = await upsertFact({
307+
sourceTask: "t1",
308+
factText: "Fede prefers concise responses",
309+
category: "preference",
310+
});
311+
312+
expect(outcome).toBe("inserted");
313+
expect(jmeStats().factsTotal).toBe(1);
314+
});
315+
316+
it("skips fact when TRUE cosine similarity >= SKIP_THRESHOLD (near-identical)", async () => {
317+
const { upsertFact, jmeStats, CONSOLIDATOR_SKIP_THRESHOLD } =
318+
await getJme();
319+
const {
320+
embed: embedMock,
321+
cosineSimilarity: cosSim,
322+
deserializeEmbedding: deser,
323+
serializeEmbedding: ser,
324+
} = await import("./embeddings.js");
325+
326+
const vec = new Float32Array([1, 0, 0]);
327+
const blob = Buffer.from(vec.buffer);
328+
329+
// Seed fact WITH a stored embedding
330+
vi.mocked(embedMock).mockResolvedValue(vec);
331+
vi.mocked(ser).mockReturnValue(blob);
332+
await upsertFact({
333+
sourceTask: "t1",
334+
factText: "Fede likes coffee",
335+
category: "preference",
336+
});
337+
338+
// Second upsert: cosine reads near-identical everywhere (queryMemory's
339+
// candidate scan AND the true-cosine re-check both go through cosSim)
340+
vi.mocked(deser).mockReturnValue(vec);
341+
vi.mocked(cosSim).mockReturnValue(CONSOLIDATOR_SKIP_THRESHOLD + 0.01);
342+
343+
const outcome = await upsertFact({
344+
sourceTask: "t2",
345+
factText: "Fede likes coffee",
346+
category: "preference",
347+
});
348+
349+
expect(outcome).toBe("skipped");
350+
expect(jmeStats().factsTotal).toBe(1); // still only 1 fact
351+
352+
// restore defaults
353+
vi.mocked(embedMock).mockResolvedValue(null);
354+
vi.mocked(cosSim).mockReturnValue(0);
355+
vi.mocked(ser).mockImplementation((v: Float32Array) =>
356+
Buffer.from(v.buffer, v.byteOffset, v.byteLength),
357+
);
358+
});
359+
360+
it("supersedes the MATCHED row when cosine is between DEDUP and SKIP", async () => {
361+
const {
362+
upsertFact,
363+
jmeStats,
364+
CONSOLIDATOR_DEDUP_THRESHOLD,
365+
CONSOLIDATOR_SKIP_THRESHOLD,
366+
} = await getJme();
367+
const {
368+
embed: embedMock,
369+
cosineSimilarity: cosSim,
370+
deserializeEmbedding: deser,
371+
serializeEmbedding: ser,
372+
} = await import("./embeddings.js");
373+
374+
const vec = new Float32Array([1, 0, 0]);
375+
const blob = Buffer.from(vec.buffer);
376+
377+
vi.mocked(embedMock).mockResolvedValue(vec);
378+
vi.mocked(ser).mockReturnValue(blob);
379+
await upsertFact({
380+
sourceTask: "t1",
381+
factText: "Fede uses Valle de Bravo to rest",
382+
category: "event",
383+
});
384+
const seeded = mockDb
385+
.prepare(`SELECT id FROM jme_facts LIMIT 1`)
386+
.get() as { id: number };
387+
388+
const midSim =
389+
(CONSOLIDATOR_DEDUP_THRESHOLD + CONSOLIDATOR_SKIP_THRESHOLD) / 2;
390+
vi.mocked(deser).mockReturnValue(vec);
391+
vi.mocked(cosSim).mockReturnValue(midSim);
392+
393+
const outcome = await upsertFact({
394+
sourceTask: "t2",
395+
factText: "Fede uses Valle de Bravo for rest and recovery",
396+
category: "event",
397+
});
398+
399+
expect(outcome).toBe("superseded");
400+
// Old fact expired + new fact inserted = 2 rows total
401+
expect(jmeStats().factsTotal).toBe(2);
402+
// W2: the supersede must expire the MATCHED row (by id), not an arbitrary one
403+
const oldRow = mockDb
404+
.prepare(`SELECT expires_at FROM jme_facts WHERE id = ?`)
405+
.get(seeded.id) as { expires_at: number | null };
406+
expect(oldRow.expires_at).not.toBeNull();
407+
408+
// restore
409+
vi.mocked(embedMock).mockResolvedValue(null);
410+
vi.mocked(cosSim).mockReturnValue(0);
411+
vi.mocked(ser).mockImplementation((v: Float32Array) =>
412+
Buffer.from(v.buffer, v.byteOffset, v.byteLength),
413+
);
414+
});
415+
416+
it("NEVER skips on the FTS-only path — keyword overlap is not similarity (audit C2)", async () => {
417+
const { upsertFact, jmeStats, writeFact } = await getJme();
418+
// embed stays null (default): the fused score would be keyword-only, with
419+
// the top FTS hit normalized to 1.0 — the exact condition that silently
420+
// dropped unrelated facts pre-fix.
421+
await writeFact({
422+
sourceTask: "t1",
423+
factText: "Fede prefers dark roast coffee in the morning",
424+
category: "preference",
425+
});
426+
427+
const outcome = await upsertFact({
428+
sourceTask: "t2",
429+
factText: "The coffee machine in the office broke yesterday",
430+
category: "event",
431+
});
432+
433+
expect(outcome).toBe("inserted");
434+
expect(jmeStats().factsTotal).toBe(2);
435+
});
436+
437+
it("clamps out-of-range confidence into [0,1] (audit W3)", async () => {
438+
const { upsertFact } = await getJme();
439+
await upsertFact({
440+
sourceTask: "t1",
441+
factText: "Fede runs a VPS with mission-control",
442+
category: "project",
443+
confidence: 7,
444+
});
445+
const row = mockDb
446+
.prepare(`SELECT confidence FROM jme_facts LIMIT 1`)
447+
.get() as { confidence: number };
448+
expect(row.confidence).toBe(1);
449+
});
450+
});
451+
452+
describe("JME — consolidateAll (nightly batch)", () => {
453+
beforeEach(() => {
454+
inferMock.mockReset();
455+
});
456+
457+
/** Insert a turn old enough for the consolidator's 30-min settle window. */
458+
function insertSettledTurn(taskId: string, role: string, content: string) {
459+
mockDb
460+
.prepare(
461+
`INSERT INTO jme_turns (task_id, role, content, channel, ts) VALUES (?, ?, ?, ?, ?)`,
462+
)
463+
.run(taskId, role, content, "telegram", Date.now() - 31 * 60 * 1000);
464+
}
465+
466+
it("consolidates settled turns ACROSS tasks in one call and deletes exactly them", async () => {
467+
const { consolidateAll, jmeStats } = await getJme();
468+
469+
insertSettledTurn("task-a", "user", "I prefer short answers");
470+
insertSettledTurn("task-a", "jarvis", "Noted!");
471+
insertSettledTurn("task-b", "user", "Vamos a despertar Pipesong");
472+
473+
inferMock.mockResolvedValueOnce({
474+
content: JSON.stringify([
475+
{
476+
factText: "Fede prefers short answers",
477+
category: "preference",
478+
confidence: 0.9,
479+
},
480+
]),
481+
});
482+
483+
const result = await consolidateAll();
484+
485+
expect(result.turnsProcessed).toBe(3); // one batch, both tasks
486+
expect(inferMock).toHaveBeenCalledTimes(1); // ONE Haiku call for the window
487+
expect(result.factsExtracted).toBe(1);
488+
expect(result.factsInserted).toBe(1);
489+
expect(jmeStats().turnsTotal).toBe(0);
490+
expect(jmeStats().factsTotal).toBe(1);
491+
});
492+
493+
it("leaves turns younger than the settle window for the next run", async () => {
494+
const { consolidateAll, writeEpisodic, jmeStats } = await getJme();
495+
496+
insertSettledTurn("task-old", "user", "settled message");
497+
writeEpisodic({ taskId: "task-live", role: "user", content: "just now" });
498+
inferMock.mockResolvedValueOnce({ content: "[]" });
499+
500+
const result = await consolidateAll();
501+
502+
expect(result.turnsProcessed).toBe(1);
503+
expect(jmeStats().turnsTotal).toBe(1); // the fresh turn survives
504+
});
505+
506+
it("returns zeros without calling Haiku when nothing is settled", async () => {
507+
const { consolidateAll, writeEpisodic } = await getJme();
508+
509+
writeEpisodic({ taskId: "task-live", role: "user", content: "hi" });
510+
511+
const result = await consolidateAll();
512+
513+
expect(result.turnsProcessed).toBe(0);
514+
expect(inferMock).not.toHaveBeenCalled();
515+
});
516+
517+
it("a valid empty [] consumes the window (nothing durable is a valid verdict)", async () => {
518+
const { consolidateAll, jmeStats } = await getJme();
519+
520+
insertSettledTurn("task-empty", "user", "hola");
521+
inferMock.mockResolvedValueOnce({ content: "[]" });
522+
523+
const result = await consolidateAll();
524+
525+
expect(result.factsExtracted).toBe(0);
526+
expect(jmeStats().turnsTotal).toBe(0); // consumed
527+
});
528+
529+
it("an EMPTY Haiku response retains the turns (transient failure, not a verdict)", async () => {
530+
const { consolidateAll, jmeStats } = await getJme();
531+
532+
insertSettledTurn("task-blank", "user", "hola");
533+
inferMock.mockResolvedValueOnce({ content: "" });
534+
535+
const result = await consolidateAll();
536+
537+
expect(result.factsExtracted).toBe(0);
538+
expect(jmeStats().turnsTotal).toBe(1); // NOT deleted — retried next run
539+
});
540+
541+
it("malformed JSON leaves the turns IN PLACE for tomorrow's retry", async () => {
542+
const { consolidateAll, jmeStats } = await getJme();
543+
544+
insertSettledTurn("task-bad", "user", "hello");
545+
inferMock.mockResolvedValueOnce({ content: "not valid json {{{" });
546+
547+
const result = await consolidateAll();
548+
549+
expect(result.factsExtracted).toBe(0);
550+
expect(jmeStats().turnsTotal).toBe(1); // NOT deleted — unconsumed data
551+
});
552+
});
553+
554+
// ---------------------------------------------------------------------------
555+
// pruneStaleTurns — global 7d sweep
556+
// ---------------------------------------------------------------------------
557+
describe("JME — pruneStaleTurns", () => {
558+
beforeEach(() => {
559+
vi.resetModules();
560+
vi.clearAllMocks();
561+
});
562+
563+
it("removes turns older than TURN_RETENTION_DAYS and leaves recent ones", async () => {
564+
const { writeEpisodic, pruneStaleTurns, TURN_RETENTION_DAYS } = await getJme();
565+
566+
// Write two turns — one recent, one >7d old
567+
writeEpisodic({ taskId: "task-recent", role: "user", content: "fresh" });
568+
569+
// Manually backdate a turn by injecting directly into mockDb (column is `ts`)
570+
// Same unit production writes: writeEpisodic stores Date.now() MILLISECONDS
571+
// (audit C1: the old seconds-based fixture masked the units mismatch).
572+
const oldTimestamp = Date.now() - (TURN_RETENTION_DAYS + 1) * 86_400_000;
573+
mockDb.prepare(
574+
`INSERT INTO jme_turns (task_id, role, content, channel, ts) VALUES (?, ?, ?, ?, ?)`,
575+
).run("task-old", "user", "stale content", "telegram", oldTimestamp);
576+
577+
const deleted = pruneStaleTurns();
578+
579+
// The stale turn should be deleted, the recent one preserved
580+
expect(deleted).toBe(1);
581+
const remaining = mockDb.prepare(`SELECT task_id FROM jme_turns`).all() as { task_id: string }[];
582+
const ids = remaining.map((r) => r.task_id);
583+
expect(ids).toContain("task-recent");
584+
expect(ids).not.toContain("task-old");
585+
});
586+
587+
it("returns 0 when no stale turns exist", async () => {
588+
const { writeEpisodic, pruneStaleTurns } = await getJme();
589+
590+
writeEpisodic({ taskId: "task-new", role: "user", content: "brand new" });
591+
592+
const deleted = pruneStaleTurns();
593+
expect(deleted).toBe(0);
594+
});
595+
});

0 commit comments

Comments
 (0)