|
12 | 12 | import { describe, it, expect, beforeEach, vi } from "vitest"; |
13 | 13 | import Database from "better-sqlite3"; |
14 | 14 |
|
| 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 | + |
15 | 24 | // ── Mock DB & dependencies ─────────────────────────────────────────────────── |
16 | 25 |
|
17 | 26 | // We create an in-memory DB and run the JME schema manually |
@@ -286,3 +295,301 @@ describe("JME — stats", () => { |
286 | 295 | expect(stats.factsWithEmbedding).toBe(0); // embed() returns null in tests |
287 | 296 | }); |
288 | 297 | }); |
| 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