diff --git a/src/core/search/hybrid.ts b/src/core/search/hybrid.ts index d6d7b1590..cdfc77978 100644 --- a/src/core/search/hybrid.ts +++ b/src/core/search/hybrid.ts @@ -860,6 +860,41 @@ export async function embedQueryBounded( } } +/** + * #3442 — resolve the public `since`/`until` contract (SearchOpts v0.29.1): + * ISO-8601 passes through, relative durations ('7d', '2w', '1y') resolve to a + * concrete timestamp, and a plain YYYY-MM-DD `until` lands at end-of-day. + * The relative form was documented since v0.29.1 but never implemented — the + * raw string ('60d') flowed into the engines' `::timestamptz` casts, every + * arm failed fail-open, and the date filter was SILENTLY ignored. + * Unparseable input now throws loudly instead of degrading. + */ +export function resolveDateBoundary( + raw: string | undefined, + boundary: 'since' | 'until', +): string | undefined { + if (raw === undefined || raw === null) return undefined; + const s = String(raw).trim(); + if (!s) return undefined; + const rel = /^(\d+)\s*([dwmy])$/i.exec(s); + if (rel) { + const n = parseInt(rel[1], 10); + const unit = rel[2].toLowerCase(); + // m = months (30d). Minutes make no sense for an effective_date filter. + const days = unit === 'd' ? n : unit === 'w' ? n * 7 : unit === 'm' ? n * 30 : n * 365; + return new Date(Date.now() - days * 86400000).toISOString(); + } + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { + // Plain date: `until` lands at end-of-day (documented SearchOpts + // semantics); `since` keeps UTC start-of-day. + return boundary === 'until' ? `${s}T23:59:59.999Z` : s; + } + if (Number.isFinite(Date.parse(s))) return s; + throw new Error( + `Invalid ${boundary} value "${s}" — expected ISO-8601 (YYYY-MM-DD or timestamp) or a relative duration like '7d', '2w', '1y'.`, + ); +} + export async function hybridSearch( engine: BrainEngine, query: string, @@ -954,8 +989,10 @@ export async function hybridSearch( // v0.29.1: since/until take precedence over deprecated afterDate/beforeDate. // The engine still consumes the legacy field names; this aliasing keeps // PR #618 callers compiling while the new names are the public surface. - afterDate: opts?.since ?? opts?.afterDate, - beforeDate: opts?.until ?? opts?.beforeDate, + // #3442: resolveDateBoundary implements the documented contract (relative + // durations + end-of-day for plain-date `until`) at this single seam. + afterDate: resolveDateBoundary(opts?.since ?? opts?.afterDate, 'since'), + beforeDate: resolveDateBoundary(opts?.until ?? opts?.beforeDate, 'until'), // v0.34.1 (#861, D9 — P0 leak seal): thread source-scoping through so the // inner engine.searchKeyword / engine.searchVector calls apply the // WHERE source_id filter at SQL level. Pre-fix, this explicit pick @@ -1803,12 +1840,19 @@ export async function hybridSearchCached( opts?.adaptiveReturn, cfgCached as unknown as Record | null, ); + // #3442: date-filtered requests skip the cache — since/until are not part + // of knobsHash, so a filtered result set could be served to an unfiltered + // lookup (and vice versa). Relative forms ('60d') also resolve to a + // now-relative timestamp, which a persisted cache row can't express. + const dateFiltered = + Boolean(opts?.since ?? opts?.afterDate) || Boolean(opts?.until ?? opts?.beforeDate); const skipCache = !cache.isEnabled() || (opts?.walkDepth ?? 0) > 0 || Boolean(opts?.nearSymbol) || isNonDefaultColumn || - adaptiveReturnOn; + adaptiveReturnOn || + dateFiltered; let cacheStatus: 'hit' | 'miss' | 'disabled' = skipCache ? 'disabled' : 'miss'; let cacheSimilarity: number | undefined; diff --git a/test/hybrid-since-relative.serial.test.ts b/test/hybrid-since-relative.serial.test.ts new file mode 100644 index 000000000..728e35436 --- /dev/null +++ b/test/hybrid-since-relative.serial.test.ts @@ -0,0 +1,132 @@ +/** + * #3442 — `query --since` relative durations ('7d', '2w', '1y'). + * + * The SearchOpts contract (types.ts, since v0.29.1) and the query op's + * --help both document relative durations, but the raw string ('60d') + * flowed straight into the engines' `::timestamptz` casts. Every search + * arm failed fail-open and the date filter was SILENTLY ignored — the + * command still returned results, so the user could not tell the filter + * never applied. + * + * Fix: resolveDateBoundary at the single hybridSearch seam resolves + * relative durations to concrete timestamps, lands a plain YYYY-MM-DD + * `until` at end-of-day (also documented, also never implemented), and + * throws loudly on unparseable input instead of degrading. + * + * Serial: mutates OPENAI_API_KEY to force the keyword-only path. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { hybridSearch, resolveDateBoundary } from '../src/core/search/hybrid.ts'; +import type { PageInput } from '../src/core/types.ts'; + +const DAY_MS = 86400000; + +describe('resolveDateBoundary (#3442)', () => { + test('relative durations resolve to a concrete past timestamp', () => { + const cases: Array<[string, number]> = [ + ['7d', 7], + ['60d', 60], + ['2w', 14], + ['3m', 90], + ['1y', 365], + ]; + for (const [raw, days] of cases) { + const out = resolveDateBoundary(raw, 'since'); + expect(out).toBeDefined(); + const delta = Date.now() - Date.parse(out!); + // Within a minute of the expected offset. + expect(Math.abs(delta - days * DAY_MS)).toBeLessThan(60_000); + } + }); + + test('ISO dates and timestamps pass through', () => { + expect(resolveDateBoundary('2026-06-01', 'since')).toBe('2026-06-01'); + expect(resolveDateBoundary('2026-06-01T10:00:00Z', 'since')).toBe('2026-06-01T10:00:00Z'); + }); + + test('plain-date `until` lands at end-of-day (documented SearchOpts semantics)', () => { + expect(resolveDateBoundary('2026-06-01', 'until')).toBe('2026-06-01T23:59:59.999Z'); + }); + + test('empty/undefined stay undefined', () => { + expect(resolveDateBoundary(undefined, 'since')).toBeUndefined(); + expect(resolveDateBoundary(' ', 'since')).toBeUndefined(); + }); + + test('unparseable input throws loudly instead of silently degrading', () => { + expect(() => resolveDateBoundary('sixty days', 'since')).toThrow(/Invalid since value/); + expect(() => resolveDateBoundary('60x', 'until')).toThrow(/Invalid until value/); + }); +}); + +describe('hybridSearch since/until end-to-end (#3442)', () => { + let engine: PGLiteEngine; + const savedKey = process.env.OPENAI_API_KEY; + + beforeAll(async () => { + delete process.env.OPENAI_API_KEY; // keyword-only path, no embedding calls + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + await engine.putPage('notes/widget-old', { + type: 'note', + title: 'Widget Old', + compiled_truth: + 'Widget foundation repair notes from the original crawlspace assessment two years back.', + }); + await engine.putPage('notes/widget-new', { + type: 'note', + title: 'Widget New', + compiled_truth: + 'Widget foundation repair follow-up: contractor quote for the pier replacement arrived yesterday.', + }); + // putPage does not chunk; give the keyword arm content to match. + await engine.upsertChunks('notes/widget-old', [{ + chunk_index: 0, + chunk_text: 'Widget foundation repair notes from the original crawlspace assessment two years back.', + chunk_source: 'compiled_truth', + }]); + await engine.upsertChunks('notes/widget-new', [{ + chunk_index: 0, + chunk_text: 'Widget foundation repair follow-up: contractor quote for the pier replacement arrived yesterday.', + chunk_source: 'compiled_truth', + }]); + await engine.executeRaw( + `UPDATE pages SET effective_date = $1 WHERE slug = 'notes/widget-old'`, + [new Date(Date.now() - 400 * DAY_MS).toISOString()], + ); + await engine.executeRaw( + `UPDATE pages SET effective_date = $1 WHERE slug = 'notes/widget-new'`, + [new Date(Date.now() - 1 * DAY_MS).toISOString()], + ); + }); + + afterAll(async () => { + if (savedKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = savedKey; + await engine.disconnect(); + }); + + test('since "60d" filters out old pages (was silently ignored)', async () => { + const out = await hybridSearch(engine, 'widget repair', { since: '60d' }); + const slugs = out.map((r) => r.slug); + expect(slugs).toContain('notes/widget-new'); + expect(slugs).not.toContain('notes/widget-old'); + }); + + test('until "60d" filters out recent pages', async () => { + const out = await hybridSearch(engine, 'widget repair', { until: '60d' }); + const slugs = out.map((r) => r.slug); + expect(slugs).toContain('notes/widget-old'); + expect(slugs).not.toContain('notes/widget-new'); + }); + + test('control: no filter returns both', async () => { + const out = await hybridSearch(engine, 'widget repair', {}); + const slugs = out.map((r) => r.slug); + expect(slugs).toContain('notes/widget-new'); + expect(slugs).toContain('notes/widget-old'); + }); +});