This commit is contained in:
160
packages/hive-mind-core/tests/mind/raw-detail-lane.test.ts
Normal file
160
packages/hive-mind-core/tests/mind/raw-detail-lane.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MindDB } from '../../src/mind/db.js';
|
||||
import { FrameStore } from '../../src/mind/frames.js';
|
||||
import { SessionStore } from '../../src/mind/sessions.js';
|
||||
import { rawTurnHeader } from '../../src/harvest/raw-turns.js';
|
||||
import { fetchRawDetailLane, rawTurnBody } from '../../src/mind/raw-detail-lane.js';
|
||||
import type { Reranker } from '../../src/mind/inprocess-reranker.js';
|
||||
|
||||
/**
|
||||
* W4.6 — RAWDETAIL recall lane: pool (window/FTS) → CE top-K → ±1 dialogue
|
||||
* neighbors → chronological order, excluding already-rendered frames.
|
||||
*/
|
||||
|
||||
/** Deterministic fake CE: score = number of marker words present in the doc. */
|
||||
function markerReranker(markers: string[]): Reranker {
|
||||
return {
|
||||
scoreBatch: async (_q: string, docs: string[]) =>
|
||||
docs.map(d => markers.reduce((s, m) => s + (d.includes(m) ? 1 : 0), 0)),
|
||||
} as Reranker;
|
||||
}
|
||||
|
||||
describe('W4.6 — fetchRawDetailLane', () => {
|
||||
let db: MindDB;
|
||||
let frames: FrameStore;
|
||||
let gopId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MindDB(':memory:');
|
||||
frames = new FrameStore(db);
|
||||
gopId = new SessionStore(db).create().gop_id;
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
/** Seed one conversation of consecutive turns, one day apart. */
|
||||
function seedConv(conv: string, texts: string[], baseDay = 10): number[] {
|
||||
const ids: number[] = [];
|
||||
texts.forEach((text, i) => {
|
||||
const day = String(baseDay + i).padStart(2, '0');
|
||||
const f = frames.createIFrame(
|
||||
gopId,
|
||||
`${rawTurnHeader(conv, i, i % 2 === 0 ? 'user' : 'assistant')}\n${text}`,
|
||||
'normal',
|
||||
'import',
|
||||
`2026-05-${day}T12:00:00Z`,
|
||||
);
|
||||
ids.push(f.id);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
it('returns CE-top turns expanded with ±1 dialogue neighbors, chronological', async () => {
|
||||
seedConv('conv-a', [
|
||||
'we talked about logistics planning',
|
||||
'I saw a painting of a sunset with a pink sky', // CE hit (turn 1)
|
||||
'it was at the Mauritshuis museum',
|
||||
'unrelated chatter about lunch options',
|
||||
]);
|
||||
const hits = await fetchRawDetailLane(
|
||||
db.getDatabase(),
|
||||
'what painting did they discuss',
|
||||
markerReranker(['painting']),
|
||||
{ k: 1 },
|
||||
);
|
||||
// turn 1 + neighbors 0 and 2, in dialogue order
|
||||
expect(hits.map(h => h.turn)).toEqual([0, 1, 2]);
|
||||
expect(hits[1].speaker).toBe('assistant');
|
||||
expect(rawTurnBody(hits[1].content)).toContain('pink sky');
|
||||
});
|
||||
|
||||
it('respects excludeIds (already-rendered frames never double-render)', async () => {
|
||||
const ids = seedConv('conv-b', [
|
||||
'first turn about painting brushes',
|
||||
'second turn about painting canvases',
|
||||
'third turn about painting frames',
|
||||
]);
|
||||
const hits = await fetchRawDetailLane(
|
||||
db.getDatabase(),
|
||||
'painting supplies',
|
||||
markerReranker(['painting']),
|
||||
{ k: 1, excludeIds: new Set([ids[0]]) },
|
||||
);
|
||||
expect(hits.map(h => h.id)).not.toContain(ids[0]);
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('date window restricts the pool to in-window turns', async () => {
|
||||
seedConv('conv-c', [
|
||||
'painting discussion in early may', // 2026-05-10
|
||||
'painting discussion mid may', // 2026-05-11
|
||||
'painting discussion late may', // 2026-05-12
|
||||
]);
|
||||
const hits = await fetchRawDetailLane(
|
||||
db.getDatabase(),
|
||||
'painting discussion',
|
||||
markerReranker(['painting']),
|
||||
{ k: 3, window: { since: '2026-05-11', until: '2026-05-11' } },
|
||||
);
|
||||
// pool = only the mid-may turn; neighbor expansion may pull ±1 — but the
|
||||
// CE top itself must be the in-window turn
|
||||
expect(hits.some(h => h.content.includes('mid may'))).toBe(true);
|
||||
});
|
||||
|
||||
it('empty window falls back to FTS so the lane never loses recall', async () => {
|
||||
seedConv('conv-d', ['the gallery showed a watercolor landscape']);
|
||||
const hits = await fetchRawDetailLane(
|
||||
db.getDatabase(),
|
||||
'watercolor landscape gallery',
|
||||
markerReranker(['watercolor']),
|
||||
{ window: { since: '2020-01-01', until: '2020-01-31' } },
|
||||
);
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns [] when no raw turns exist', async () => {
|
||||
frames.createIFrame(gopId, 'plain frame about painting', 'normal', 'system');
|
||||
const hits = await fetchRawDetailLane(
|
||||
db.getDatabase(),
|
||||
'painting',
|
||||
markerReranker(['painting']),
|
||||
);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('soft-fails to [] when the reranker throws', async () => {
|
||||
seedConv('conv-e', ['painting one', 'painting two']);
|
||||
const broken = { scoreBatch: async () => { throw new Error('model load failed'); } } as unknown as Reranker;
|
||||
const hits = await fetchRawDetailLane(db.getDatabase(), 'painting', broken);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('neighbor expansion never crosses conversations', async () => {
|
||||
seedConv('conv-f', ['solo painting turn in conv f']);
|
||||
seedConv('conv-g', ['unrelated turn in conv g'], 20);
|
||||
const hits = await fetchRawDetailLane(
|
||||
db.getDatabase(),
|
||||
'painting',
|
||||
markerReranker(['painting']),
|
||||
{ k: 1 },
|
||||
);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].conv).toBe('conv-f');
|
||||
});
|
||||
|
||||
it('pools Cyrillic raw turns via FTS (S1 Unicode sanitizer)', async () => {
|
||||
seedConv('conv-cy', [
|
||||
'разговор о логистици и плановима',
|
||||
'видели смо слику заласка сунца у Београду',
|
||||
'посета музеју је била сјајна',
|
||||
]);
|
||||
const hits = await fetchRawDetailLane(
|
||||
db.getDatabase(),
|
||||
'слику Београду',
|
||||
markerReranker(['слику']),
|
||||
{ k: 1 },
|
||||
);
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
expect(hits.some(h => rawTurnBody(h.content).includes('слику'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user