diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea723e8..de990a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are recorded here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/). +## 0.1.1 — 2026-10-15 (hotfix: relaxed recall rung, ADR-0102) + +### Fixed + +- **recall 自然语言提问取不回已存证据(ADR-0102 D1)**:`recall_memory` 的内部记忆与项目索引两层都只走严格 FTS5 梯级(全词 AND + 整句),宿主自然语言提问几乎从不在单行里凑齐全部词,存储中的证据取不回。现严格梯级不足 `limit` 时追加放宽梯级(实词 OR + 前缀、同一 BM25/新鲜度排序,严格命中保持优先);覆盖 `searchMemory` / `searchMemoryMultiQuery` / `ProjectIndexStore.search` 三条路径。回归:`packages/store/test/fts5-relaxed-recall.test.ts`、`apps/plugin/test/project-index-relaxed.test.ts`。 + +### Changed + +- 全包版本 0.1.0 → 0.1.1(9 package.json + plugin.json + ship-gate release pin + provider clientInfo 同 commit)。本版同时承载下列 Unreleased(ADR-0094~0101)条目。 + ## Unreleased — ADR-0101 r100: 元验证封顶(封顶滤尺三交集 + 修层/新层判据 + 坐席 Addendum + tau 递归/AST 前门 + deferred 触发器) ### Added diff --git a/CONTEXT.md b/CONTEXT.md index ba276f44..73357d87 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1872,3 +1872,8 @@ closeout 在 main 上含活链 Stack 行且其命名 branch 的 origin ref 缺 ### Masking-Surfaced(掩盖浮面) runner 对 open seat 下探针报 no-kill 出具名 masking info 行——哪个坐席掩盖哪个锚,每跑可见。_Avoid_: 泛泛一行 info;坐席掩盖保持隐形。来源:R100 D-004。 + +## Hotfix 0.1.1 — Terms + +### Relaxed Recall Rung(召回放宽梯级) +recall 的第二级 FTS5 查询:严格梯级(ADR-0023 D2:全词 AND + 整句)命中不足 limit 时才启用,实词 OR + 前缀匹配、同一 BM25 排序,结果追加在严格命中之后、按行身份去重。目的:宿主以自然语言提问时不再因「无单行含全部词」而取不回已存证据。ADR-0102 D1。_Avoid_: 不用放宽梯级替代严格梯级(精确查询的排序与优先级不变);不把它当语义召回(向量/语义臂另见 ADR-0037)。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 0a4dec58..7da3361b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/cli", - "version": "0.1.0", + "version": "0.1.1", "bin": { "ans": "dist/index.js" }, diff --git a/apps/dsh-plugin/package.json b/apps/dsh-plugin/package.json index 57ed4dee..4b032988 100644 --- a/apps/dsh-plugin/package.json +++ b/apps/dsh-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/dsh-plugin", - "version": "0.1.0", + "version": "0.1.1", "private": false, "publishConfig": { "access": "public", diff --git a/apps/dsh-plugin/src/index.ts b/apps/dsh-plugin/src/index.ts index 43360ef2..cea4ec0c 100644 --- a/apps/dsh-plugin/src/index.ts +++ b/apps/dsh-plugin/src/index.ts @@ -137,7 +137,7 @@ const channels = new Map>(); async function mcpHandshake(base: string, token: string): Promise { const init = await callServer(base + '/mcp', token, { jsonrpc: '2.0', id: ++rpcSeq, method: 'initialize', - params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'anysearch-dsh-plugin', version: '0.1.0' } }, + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'anysearch-dsh-plugin', version: '0.1.1' } }, }, { headers: { Accept: MCP_ACCEPT }, timeoutMs: 15_000 }); const negotiated = (init?.result as { protocolVersion?: unknown } | undefined)?.protocolVersion; if (typeof negotiated !== 'string' || negotiated.length === 0) return null; diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 0c8bd892..9711fe4a 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/mcp", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "bin": { "ans-mcp": "dist/index.cjs" diff --git a/apps/plugin/.claude-plugin/plugin.json b/apps/plugin/.claude-plugin/plugin.json index 629ef31a..37f2396a 100644 --- a/apps/plugin/.claude-plugin/plugin.json +++ b/apps/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "anysearch", - "version": "0.1.0", + "version": "0.1.1", "description": "AnySearch hooks + MCP integration for Claude Code (experimental)", "author": { "name": "Xxx91n" diff --git a/apps/plugin/package.json b/apps/plugin/package.json index c4ddb3f3..262dee03 100644 --- a/apps/plugin/package.json +++ b/apps/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/plugin", - "version": "0.1.0", + "version": "0.1.1", "exports": { ".": { "types": "./src/index.ts", diff --git a/apps/plugin/src/store/project-index-store.ts b/apps/plugin/src/store/project-index-store.ts index 00a44ce3..b54196fb 100644 --- a/apps/plugin/src/store/project-index-store.ts +++ b/apps/plugin/src/store/project-index-store.ts @@ -3,7 +3,7 @@ // Written by hooks layer via IPC to long-running MCP server (ADR-0009 Decision 1+2). import Database from "better-sqlite3"; -import { fts5EscapeQuery } from "@anysearch-cli/store"; +import { fts5EscapeQuery, fts5RelaxedQuery, relaxedTerms, coversMajority, appendRelaxed } from "@anysearch-cli/store"; export interface ProjectIndexEntry { rowid: number; @@ -133,7 +133,17 @@ export class ProjectIndexStore { search(query: string, limit = 10): ProjectIndexHit[] { const safeQuery = this.fts5Escape(query); - return this.stmts.search.all(safeQuery, limit) as ProjectIndexHit[]; + const strict = this.stmts.search.all(safeQuery, limit) as ProjectIndexHit[]; + // ADR-0102 D1: host recall questions are natural language — relax when the strict rung under-fills. + if (strict.length >= limit) return strict; + const relaxed = fts5RelaxedQuery(query); + if (!relaxed) return strict; + // `p.rowid` on an INTEGER PRIMARY KEY table comes back under the PK's name (`id`), so dedupe on it. + const idOf = (h: ProjectIndexHit) => (h as unknown as { id?: number }).id ?? h.rowid; + const terms = relaxedTerms(query); + const wide = (this.stmts.search.all(relaxed, limit * 4) as ProjectIndexHit[]) + .filter((h) => coversMajority(terms, `${h.title ?? ""} ${h.snippet ?? ""} ${h.url ?? ""}`)); + return appendRelaxed(strict, wide, limit, idOf); } purgeProject(projectPath: string): number { diff --git a/apps/plugin/test/project-index-relaxed.test.ts b/apps/plugin/test/project-index-relaxed.test.ts new file mode 100644 index 00000000..6b941501 --- /dev/null +++ b/apps/plugin/test/project-index-relaxed.test.ts @@ -0,0 +1,42 @@ +// ADR-0102 D1: the project index (host-recallable memory) gets the same relaxed rung. +// Repro: the host indexed NBA Finals results in session 1, then asked a natural-language +// question in session 2; the strict AND rung returned 0 although 85 rows were indexed. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ProjectIndexStore } from "../src/index.js"; + +function seeded() { + const dir = mkdtempSync(join(tmpdir(), "ans-pi-relax-")); + const store = new ProjectIndexStore(join(dir, "pi.db")); + const rows = [ + ["Knicks win 2026 NBA Finals", "https://www.nba.com/news/knicks-win-2026-finals", "New York beat San Antonio 4-1 to claim the championship"], + ["Brunson named NBA Finals MVP", "https://www.espn.com/nba/story/brunson-finals-mvp", "Jalen Brunson scored 45 in Game 5"], + ["NBA preseason schedule", "https://example.com/nba-schedule", "Preseason games start in October"], + ["Tomato pruning basics", "https://example.com/gardening", "Prune suckers early in the season"], + ]; + rows.forEach(([title, url, snippet], i) => store.indexEntry({ projectPath: dir, toolName: "search_web", title, url, snippet, source: "tavily", contentHash: "h" + i })); + return { store, dir }; +} + +test("project index: a natural-language follow-up question recalls indexed evidence (was 0)", () => { + const { store, dir } = seeded(); + try { + const hits = store.search("latest NBA champion NBA Finals MVP", 5); + assert.ok(hits.length >= 2, `expected the two NBA rows, got ${hits.length}`); + assert.ok(hits.every((h) => /Knicks|Brunson/.test(h.title)), "one-word near-miss rows stay out"); + } finally { store.close(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("project index: strict matches keep priority; unrelated queries stay empty", () => { + const { store, dir } = seeded(); + try { + const top = store.search("Brunson Finals MVP", 1); + assert.equal(top.length, 1); + assert.match(top[0]!.title, /Brunson/); + assert.equal(store.search("quantum zebra telescope", 5).length, 0); + assert.equal(store.search("NBA ticket refund policy", 5).length, 0, "single shared entity word is not evidence"); + } finally { store.close(); rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/docs/adr/0102-hotfix-0-1-1-relaxed-recall-rung.md b/docs/adr/0102-hotfix-0-1-1-relaxed-recall-rung.md new file mode 100644 index 00000000..98bff863 --- /dev/null +++ b/docs/adr/0102-hotfix-0-1-1-relaxed-recall-rung.md @@ -0,0 +1,51 @@ +# ADR-0102: Hotfix 0.1.1 — 召回放宽梯级(Relaxed Recall Rung) + +## Status + +Accepted (hotfix 0.1.1, 2026-10-15). Ledger: `.scratch/hotfix-0.1.1/decision-ledger.md` (D-001~D-002). Amends ADR-0023 D2 (FTS5 query tokenization) for the recall paths only; ADR-0009 D4 (two-stage recall) unchanged. + +## Context + +ADR-0023 D2 tokenizes a query into `(w1* AND w2* … AND wN*) OR "full phrase"`. That is precise for short keyword queries, but a host agent asks `recall_memory` in natural language. A host-in-loop run (Codex host, 2026-10-15) showed the effect: + +- **Session 1** researched the 2026 NBA Finals. Its results reached both memory layers: 111 rows in `retrieval_results`, 85 rows in the project index. +- **Session 2** asked a follow-up in a new session: `recall_memory("latest NBA champion NBA Finals MVP")`. +- **Result:** 0 hits in both layers, because no single stored row contains all five terms. The same terms joined with OR hit 85/111. +- **Consequence:** the host re-searched from scratch, so the stored research was unreachable. + +The defect is in the query builder, not in storage or ranking. + +## Decision + +### D1 严格优先、欠满放宽(strict rung first, relaxed rung on under-fill) + +- **Strict rung:** unchanged — `fts5EscapeQuery`, i.e. ADR-0023 D2. Its hits keep their order and priority. +- **When to relax:** only when the strict rung returns fewer than `limit` rows. +- **Relaxed rung:** `fts5RelaxedQuery`. + - Same sanitisation (CWE-20) and the same ≤8-term cap. + - Function words are dropped. + - The remaining content words are OR-ed with prefix match (`w*`). + - Rows are ranked by the same BM25 expression; the internal layer also keeps `freshness_factor`, keyed on the strict string so decay behaviour is identical. +- **Merge:** relaxed rows are appended after the strict rows, de-duplicated by row identity (`appendRelaxed`), up to `limit`. +- **No relaxing:** when fewer than two content words remain, `fts5RelaxedQuery` returns `null`. +- **Applied to all three FTS recall paths:** + - `SqliteSessionStore.searchMemory` (single query); + - `searchMemoryMultiQuery` (per variant, before RRF); + - `ProjectIndexStore.search` (project index, Stage 2). + +### D2 版本 + +All packages move to 0.1.1, published through the normal `release.yml` path (pre-tag → tag → OIDC publish). + +## Consequences + +- A natural-language recall question now reaches stored evidence. Regression tests reproduce the 0-hit case first: + - `packages/store/test/fts5-relaxed-recall.test.ts` + - `apps/plugin/test/project-index-relaxed.test.ts` +- Queries with no content-word overlap still return nothing (negative test), so abstain behaviour is unchanged. +- Golden/eval runs whose strict rung already fills `limit` are unaffected by construction. +- Noted while fixing: `ProjectIndexStore`'s `SELECT p.rowid` returns the PK under its declared name `id`, not `rowid`, so `ProjectIndexHit.rowid` is undefined at runtime. + - Dedupe in the project-index path therefore keys on `id`. + - The SQL and the `/recall` JSON shape are left unchanged. + - The type mismatch is recorded here, not fixed (no consumer reads it). +- Out of scope: semantic/vector recall arms, query rewrite (ADR-0023 D1) and fusion weights (ADR-0045). diff --git a/docs/adr/index.md b/docs/adr/index.md index ba5d1c7a..5aece940 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -1,7 +1,7 @@ # Architecture decisions -The complete numbered record lives in `docs/adr/` — ADR-0001 through ADR-0101. +The complete numbered record lives in `docs/adr/` — ADR-0001 through ADR-0102. | ADR | Title | |-----|-------| @@ -106,4 +106,5 @@ The complete numbered record lives in `docs/adr/` — ADR-0001 through ADR-0101. | [0099](0099-architecture-grill-round-98-fifth-morphology.md) | Grill Round 98 — 第五形态:装饰性机器约束检测(闭锚注册表 + 行为消费验证 + 两枚自指钉) | | [0100](0100-architecture-grill-round-99-vocab-guards-open-surface-expansion.md) | Grill Round 99 — vocab-guards 扩域至全 scripts/(逐模块注册表 + glob 顶层枚举 + 逐模块证伪) | | [0101](0101-architecture-grill-round-100-meta-validation-cap.md) | Grill Round 100 — 元验证封顶(封顶滤尺三交集 + 修层/新层判据 + 坐席 Addendum + tau 递归/AST 前门 + deferred 触发器) | +| [0102](0102-hotfix-0-1-1-relaxed-recall-rung.md) | Hotfix 0.1.1 — 召回放宽梯级(Relaxed Recall Rung) | diff --git a/package.json b/package.json index 46093b0c..ce1ecaba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anysearch-cli", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "packageManager": "pnpm@11.24.0", diff --git a/packages/embedding/package.json b/packages/embedding/package.json index 2f8c5c68..e067aaeb 100644 --- a/packages/embedding/package.json +++ b/packages/embedding/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/embedding", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "exports": { ".": "./src/index.ts" diff --git a/packages/kernel/package.json b/packages/kernel/package.json index c76b09cb..d2028a47 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/kernel", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "exports": { diff --git a/packages/retriever/package.json b/packages/retriever/package.json index 8c61aebf..b9bf1261 100644 --- a/packages/retriever/package.json +++ b/packages/retriever/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/retriever", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "exports": { diff --git a/packages/retriever/src/providers/anysearch.ts b/packages/retriever/src/providers/anysearch.ts index d32476c2..0fb125ef 100644 --- a/packages/retriever/src/providers/anysearch.ts +++ b/packages/retriever/src/providers/anysearch.ts @@ -263,7 +263,7 @@ export class AnySearchProvider implements SearchProvider { const res = await this.rpc("initialize", { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "anysearch-cli", version: "0.1.0" }, + clientInfo: { name: "anysearch-cli", version: "0.1.1" }, }, signal); const negotiated = res["protocolVersion"]; if (typeof negotiated === "string" && negotiated) this.protocolVersion = negotiated; diff --git a/packages/store/package.json b/packages/store/package.json index 506001c0..e06b19a8 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@anysearch-cli/store", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "exports": { diff --git a/packages/store/src/fts5.ts b/packages/store/src/fts5.ts index 52eb20b0..184e4008 100644 --- a/packages/store/src/fts5.ts +++ b/packages/store/src/fts5.ts @@ -21,6 +21,57 @@ export function fts5EscapeQuery(query: string, maxTerms = 8): string { return "(" + prefixClause + ') OR "' + phrase + '"'; } +// ADR-0102 D1: relaxed recall rung. A natural-language recall question ("latest NBA champion NBA Finals MVP") +// rarely has every term inside one stored row, so the strict AND rung above returns zero rows and evidence +// that IS in memory becomes unreachable. Callers run the strict rung first and, only when it under-fills +// the limit, append the relaxed rung: content words (function words dropped) OR-ed with prefix match, +// ranked by the same BM25 expression. Strict hits keep their order and priority. +const RELAX_STOPWORDS = new Set([ + "a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "from", "by", "with", "about", "as", + "is", "are", "was", "were", "be", "been", "being", "it", "its", "this", "that", "these", "those", + "what", "which", "who", "whom", "whose", "when", "where", "why", "how", "do", "does", "did", + "can", "could", "would", "should", "will", "i", "you", "we", "they", "he", "she", "me", "my", "our", "your", "their", +]); + +// Content words of a query (sanitised like the strict rung, function words dropped, ≤ maxTerms). +export function relaxedTerms(query: string, maxTerms = 8): string[] { + const words = query.replace(SANITIZE_RE, " ").trim().split(/\s+/).filter(Boolean).map((w) => w.toLowerCase()); + return [...new Set(words.filter((w) => !RELAX_STOPWORDS.has(w)))].slice(0, maxTerms); +} + +// Returns null when relaxing cannot add a majority-covered row the strict rung missed +// (fewer than three content words: a strict majority of 1–2 terms is all of them). +export function fts5RelaxedQuery(query: string, maxTerms = 8): string | null { + const content = relaxedTerms(query, maxTerms); + if (content.length < 3) return null; + return content.map((w) => w + "*").join(" OR "); +} + +// ADR-0102 D1 coverage floor: a relaxed row is kept only if it covers a STRICT MAJORITY of the +// query's content words (prefix match on its indexed text). A row that shares just one entity word +// with the question ("cacheflush release timetable" vs "cacheflush interval") is not evidence — +// the golden unanswerable cases (un_*) require such near-miss distractors to stay out of the FTS arm. +export function coversMajority(terms: string[], text: string): boolean { + if (terms.length === 0) return false; + const toks = text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + let matched = 0; + for (const t of terms) if (toks.some((k) => k.startsWith(t))) matched++; + return matched * 2 > terms.length; +} + +// Append relaxed-rung rows after strict-rung rows, skipping duplicates, up to limit. +// key: row identity (default rowid; the project index returns its PK as `id`). +export function appendRelaxed(strict: T[], relaxed: T[], limit: number, key: (h: T) => unknown = (h) => (h as { rowid?: unknown }).rowid): T[] { + const out = strict.slice(0, limit); + const seen = new Set(out.map(key)); + for (const h of relaxed) { + if (out.length >= limit) break; + const k = key(h); + if (!seen.has(k)) { seen.add(k); out.push(h); } + } + return out; +} + // ADR-0023 D2 (Q2=B): multi-query + RRF (k=60) fusion for consumer queries. // queries: 3 variants from S1 rewrite ([original, v1, v2]) — index 0 is always the raw user query (fail-open). // Deduplication in rrfScores is list-local: two variants returning the same doc don't double-count when fused. @@ -44,14 +95,23 @@ export async function searchMemoryMultiQuery( if (safe === '""') continue; // ADR-0030: mirror of stmts.searchAllResults: freshness_factor UDF must be registered (see registerFreshnessFactorFunction). // Params: (query_for_decay, fts_match_query, limit) — same string passed twice for both ? slots. - const hits = store.dbQuery( + const sql = "SELECT r.id as rowid, r.session_id as sessionId, r.title as role, r.snippet as content, " + "freshness_factor(bm25(retrieval_results_fts), r.created_at, r.last_accessed, r.access_count, r.title, r.url, ?, r.pinned) as rank " + "FROM retrieval_results_fts JOIN retrieval_results r ON r.id = retrieval_results_fts.rowid " + "WHERE retrieval_results_fts MATCH ? AND (r.valid_until IS NULL) AND (r.quarantine IS NULL) AND r.archived = 0 " + - "ORDER BY rank LIMIT ?", - safe, safe, limit, - ); + "ORDER BY rank LIMIT ?"; + let hits = store.dbQuery(sql, safe, safe, limit); + // ADR-0102 D1: relaxed rung only when the strict rung under-fills (decay keyed on the same strict string). + if (hits.length < limit) { + const relaxed = fts5RelaxedQuery(q.trim()); + if (relaxed) { + const terms = relaxedTerms(q.trim()); + const wide = store.dbQuery(sql, safe, relaxed, limit * 4) + .filter((h) => coversMajority(terms, `${h.role ?? ""} ${h.content ?? ""}`)); + hits = appendRelaxed(hits, wide, limit); + } + } if (hits.length > 0) { // RRF input key must be a stable per-doc id — rowid identifies retrieval_results rows. lists.push(hits.map((h) => String(h.rowid))); diff --git a/packages/store/src/session-store.ts b/packages/store/src/session-store.ts index c252fd08..1874e6ca 100644 --- a/packages/store/src/session-store.ts +++ b/packages/store/src/session-store.ts @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { SCHEMA_SQL } from "./schema-content"; import { registerFreshnessFactorFunction, invalidateOldRecords } from "./time-decay.js"; -import { fts5EscapeQuery, searchMemoryMultiQuery } from "./fts5.js"; +import { fts5EscapeQuery, fts5RelaxedQuery, relaxedTerms, coversMajority, appendRelaxed, searchMemoryMultiQuery } from "./fts5.js"; import { extractEntityCandidates, normalizeEntityName, trigramSimilarity, ENTITY_ALIAS_THRESHOLD, ENTITY_REVIEW_THRESHOLD, MAX_ENTITY_CANDIDATES as MAX_CANDIDATES } from "./entity.js"; import { extractRelations, parseLlmTriples, dedupeTriples, patternAllows, EDGE_PATTERN_ROWS, RELATION_RULES_VERSION } from "./relation.js"; import type { ExtractedTriple, LinkedEntityRef } from "./relation.js"; @@ -608,7 +608,17 @@ export class SqliteSessionStore implements SessionStore { async searchMemory(query: string, limit = 20): Promise { const safeQuery = this.fts5Escape(query); // Params: (query_for_decay, fts_match_query, limit) — same string passed twice for both ? slots. - const rawHits = this.stmts.searchAllResults.all(safeQuery, safeQuery, limit) as MemoryHit[]; + let rawHits = this.stmts.searchAllResults.all(safeQuery, safeQuery, limit) as MemoryHit[]; + // ADR-0102 D1: strict rung under-fills → append the relaxed rung (decay keyed on the strict string). + if (rawHits.length < limit) { + const relaxed = fts5RelaxedQuery(query); + if (relaxed) { + const terms = relaxedTerms(query); + const wide = (this.stmts.searchAllResults.all(safeQuery, relaxed, limit * 4) as MemoryHit[]) + .filter((h) => coversMajority(terms, `${h.role ?? ""} ${h.content ?? ""}`)); + rawHits = appendRelaxed(rawHits, wide, limit); + } + } // ADR-0031 D4: entity arm — conditional activation (absent when query has no entity match), weight 0.5 vs FTS 1.0. const armHits = this.entityArmRows(query, limit); const vecHits = await this.vectorArmRows(query, limit); diff --git a/packages/store/test/eval-docs-golden.test.ts b/packages/store/test/eval-docs-golden.test.ts index 52c24f90..4f831910 100644 --- a/packages/store/test/eval-docs-golden.test.ts +++ b/packages/store/test/eval-docs-golden.test.ts @@ -62,6 +62,13 @@ assert(entryProblems.length === 0, "all entries valid: " + entryProblems.join(" for (const e of golden.entries) { if (e.provenance.type === "external-community") { assert(/^https?:\/\//.test(e.provenance.ref), e.id + ": external provenance is a URL"); + } else if (e.provenance.ref.startsWith(".scratch/")) { + // .scratch/ is local-only by policy since 3f85fee8 (untracked, never committed — AGENTS.md): + // a clean checkout (CI) cannot resolve it, so assert a well-formed repo-relative path instead. + assert( + /^\.scratch\/[A-Za-z0-9._\/-]+$/.test(e.provenance.ref) && !e.provenance.ref.includes(".."), + e.id + ": local-only provenance ref is a well-formed .scratch path: " + e.provenance.ref + ); } else { assert( fs.existsSync(path.join(root, e.provenance.ref)), diff --git a/packages/store/test/fts5-relaxed-recall.test.ts b/packages/store/test/fts5-relaxed-recall.test.ts new file mode 100644 index 00000000..e6a64c27 --- /dev/null +++ b/packages/store/test/fts5-relaxed-recall.test.ts @@ -0,0 +1,90 @@ +// ADR-0102 D1: relaxed recall rung. Repro from the host-in-loop follow-up: memory held the +// NBA Finals results, but the host's natural-language recall question had no single row +// containing every term, so the strict AND rung returned zero and memory was unreachable. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SqliteSessionStore } from "../src/session-store"; +import { fts5EscapeQuery, fts5RelaxedQuery, appendRelaxed, coversMajority } from "../src/fts5"; + +const ROWS = [ + { url: "https://www.nba.com/news/knicks-win-2026-finals", title: "Knicks win 2026 NBA Finals", snippet: "New York beat San Antonio 4-1 to claim the championship", source: "tavily" }, + { url: "https://www.espn.com/nba/story/brunson-finals-mvp", title: "Brunson named NBA Finals MVP", snippet: "Jalen Brunson scored 45 in Game 5", source: "exa" }, + { url: "https://example.com/nba-schedule", title: "NBA preseason schedule", snippet: "Preseason games start in October", source: "exa" }, + { url: "https://example.com/gardening", title: "Tomato pruning basics", snippet: "Prune suckers early in the season", source: "exa" }, +]; + +async function seeded() { + const dir = mkdtempSync(join(tmpdir(), "ans-relax-")); + const store = new SqliteSessionStore(join(dir, "m.db")); + const s = await store.createSession("code"); + await store.saveResults(s.id, ROWS); + await new Promise((r) => setTimeout(r, 10)); + return { store, dir }; +} + +test("relaxed query: content words OR-ed with prefix match, function words dropped, null when nothing to relax", () => { + assert.equal(fts5RelaxedQuery("Who won the latest NBA championship"), "won* OR latest* OR nba* OR championship*"); + assert.equal(fts5RelaxedQuery("NBA"), null); + assert.equal(fts5RelaxedQuery("what is the"), null); + assert.equal(fts5RelaxedQuery("NBA champion"), null, "two content words: a strict majority is both, nothing to relax"); + assert.equal(fts5RelaxedQuery('a " b; DROP c'), "b* OR drop* OR c*", "sanitised exactly like the strict rung (CWE-20)"); + assert.equal(fts5RelaxedQuery("a b c d e f g h i j k")!.split(" OR ").length, 8, "≤8 terms"); +}); + +test("coverage floor: a relaxed row must cover a strict majority of content words", () => { + assert.equal(coversMajority(["cacheflush", "release", "timetable"], "cacheflush interval config cacheflush interval is ten minutes"), false, "one shared entity word is not evidence (golden un_*)"); + assert.equal(coversMajority(["latest", "nba", "champion", "finals", "mvp"], "Knicks win 2026 NBA Finals New York claim the championship"), true); + assert.equal(coversMajority(["latest", "nba", "champion", "finals", "mvp"], "NBA preseason schedule"), false); +}); + +test("appendRelaxed keeps strict hits first, skips duplicates, respects limit", () => { + const r = appendRelaxed([{ rowid: 2 }, { rowid: 1 }], [{ rowid: 1 }, { rowid: 3 }, { rowid: 4 }], 3); + assert.deepEqual(r.map((h) => h.rowid), [2, 1, 3]); +}); + +test("searchMemory: a natural-language recall question reaches stored evidence (was 0 hits)", async () => { + const { store, dir } = await seeded(); + try { + const q = "latest NBA champion NBA Finals MVP"; + const strictOnly = store.dbQuery("SELECT rowid FROM retrieval_results_fts WHERE retrieval_results_fts MATCH ?", fts5EscapeQuery(q)); + assert.equal(strictOnly.length, 0, "repro: the strict rung alone returns nothing"); + const hits = await store.searchMemory(q, 5); + assert.ok(hits.length >= 2, `expected the two NBA rows, got ${hits.length}`); + assert.ok(hits.every((h) => /Knicks|Brunson/.test(h.role)), "only majority-covered rows; the one-word 'NBA schedule' row stays out"); + } finally { store.close?.(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("searchMemory: strict hits keep priority and a full strict result is not diluted", async () => { + const { store, dir } = await seeded(); + try { + const hits = await store.searchMemory("Brunson Finals MVP", 1); + assert.equal(hits.length, 1); + assert.match(hits[0]!.role, /Brunson/); + } finally { store.close?.(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("searchMemory: a query with no overlapping content word still returns nothing", async () => { + const { store, dir } = await seeded(); + try { + assert.equal((await store.searchMemory("quantum zebra telescope", 5)).length, 0); + } finally { store.close?.(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("searchMemoryMulti: the relaxed rung also applies per query variant", async () => { + const { store, dir } = await seeded(); + try { + const hits = await store.searchMemoryMulti(["latest NBA champion NBA Finals MVP", "who won the NBA title this year"], 5); + assert.ok(hits.length >= 1, `expected NBA rows via the multi-query path, got ${hits.length}`); + } finally { store.close?.(); rmSync(dir, { recursive: true, force: true }); } +}); + +test("searchMemory: a near-miss sharing one entity word is NOT recalled through the relaxed rung (golden un_* contract)", async () => { + const { store, dir } = await seeded(); + try { + const hits = await store.searchMemory("NBA ticket refund policy", 5); + assert.equal(hits.length, 0, `expected no evidence, got ${hits.map((h) => h.role).join(" | ")}`); + } finally { store.close?.(); rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/scripts/ship-gate.mjs b/scripts/ship-gate.mjs index c0e4849f..95e5f5bf 100644 --- a/scripts/ship-gate.mjs +++ b/scripts/ship-gate.mjs @@ -216,11 +216,11 @@ function stepStaticAssertions() { // R62 due-chore: hard pin — every workspace package must be exactly the // release version (the release candidate), not merely "not 0.0.0". // Bump this pin in the same commit as the package version bump (R66 audit F-01). - if (pkg.version !== "0.1.0") { - fail(`${rel}/package.json not pinned at 0.1.0 (got ${pkg.version}) — release pin (D-006 patch-start)`); + if (pkg.version !== "0.1.1") { + fail(`${rel}/package.json not pinned at 0.1.1 (got ${pkg.version}) — release pin (D-006 patch-start)`); } } - report("pass", `all ${PKG_DIRS.length} packages pinned at 0.1.0`); + report("pass", `all ${PKG_DIRS.length} packages pinned at 0.1.1`); // 1b. ADR-0017 dual-era: kernel and retriever must both export (ADR-0017) const kernelExports = JSON.parse(