From a6e13058aa3be7868814d5ec7c1d752b80735fa6 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Thu, 6 Aug 2026 13:36:30 +0900 Subject: [PATCH] Report unmatched repo filter in search response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search の repo フィルタが1件もマッチしなかった場合を、レスポンスの filters_unmatched フィールドで明示する。repo はフルスラッグ (owner/repo) の 完全一致なので、短いリポジトリ名を渡すと候補集合が空のまま「該当なし」と 同じ形のレスポンスが返り、呼び出し側からフィルタ不成立とヒットゼロが 区別できなかった。多段のエージェンティック検索ではゼロが正常な中間結果と して消費されるため、この silent zero は偽陰性のまま流れてクエリ予算だけを 焼く。 変更点: - fts.ts: repoHasIndexedRows (search_docs への LIMIT 1 存在確認) と detectUnmatchedFilters (候補ゼロのときだけプローブ/プローブ失敗時は 観測していない不成立を主張しない) を追加 - mcp.ts: search モードのレスポンスに filters_unmatched を常時付与し、 tool schema の repo description に完全一致であることを明記 - mcp-server/server/tools.js: 静的スキーマのミラーを同期 - テスト: 「フィルタ不成立」と「ヒットゼロ」が別レスポンスになることを node 側 (判定ロジック) と workers 側 (実 D1 の SQL) の両面で検証 - docs / README (ja/en): filters_unmatched の意味と非スコープを記載 検索ロジック (fusion / rerank / 候補数) は一切変更していない。観測性のみ。 短いスラッグからフルスラッグへの自動解決は非スコープ。 Refs #219 Co-Authored-By: Claude Opus 5 --- README.ja.md | 4 +- README.md | 4 +- docs/0-requirements.ja.md | 8 ++- docs/0-requirements.md | 8 ++- mcp-server/server/tools.js | 10 +++- mcp-server/test/search-tool-schema.test.js | 12 +++++ src/fts.test.ts | 61 +++++++++++++++++++++ src/fts.ts | 63 ++++++++++++++++++++++ src/fts.workers.test.ts | 37 +++++++++++++ src/mcp.ts | 37 ++++++++++++- 10 files changed, 236 insertions(+), 8 deletions(-) diff --git a/README.ja.md b/README.ja.md index 9a0aa50..a20eeb4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -95,6 +95,8 @@ GitHub の issue / pull request / release / documentation / **GitHub Wiki page** structured filter (`repo` / `state` / `labels` / `milestone` / `assignee` / `type`) はすべてのモードで有効です。 +search モードは「1件もマッチしなかったフィルタ」を `filters_unmatched` に載せます (常に存在し、すべて成立していれば `[]`)。`repo` はフルスラッグ `owner/repo` の完全一致なので、短いリポジトリ名を渡すと母集合が空になり、本当にヒットゼロだった場合と同じ形のレスポンスが返ります。このフィールドがその2つを区別します。効くのは多段のエージェンティック検索で、ゼロが正常な中間結果として読まれてしまい、フィルタ不成立が表に出ないまま終わる場面です。 + bot (`sender.login` が `[bot]` で終わる) と trim 後 10 文字未満の body は ingest 時点で除外されます。`LGTM` / `+1` / CI ノイズなどは retrieval 面に残りません。 #### パラメータ @@ -102,7 +104,7 @@ bot (`sender.login` が `[bot]` で終わる) と trim 後 10 文字未満の bo | 名前 | 型 | 説明 | |------|----|------| | `query` | string (省略可) | 自然言語クエリ。省略または空文字で scan モード。 | -| `repo` | string | repository (`owner/repo`) で絞り込み。 | +| `repo` | string | repository で絞り込み。フルスラッグ (`owner/repo`) の完全一致。短いリポジトリ名は1件もマッチせず、search モードはそれをレスポンスの `filters_unmatched` に `"repo"` として報告します。 | | `state` | `"open"` / `"closed"` / `"all"` | state で絞り込み (既定 `all`)。 | | `labels` | string[] | label 名で AND 絞り込み。 | | `milestone` | string | milestone title で絞り込み。 | diff --git a/README.md b/README.md index a7c777d..5ae00ec 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ Three modes are selected by the combination of `query` and `sort`: Structured filters (`repo`, `state`, `labels`, `milestone`, `assignee`, `type`) apply in every mode. +Search mode reports filters that matched nothing at all in `filters_unmatched` (always present, `[]` when every filter matched something). `repo` is an exact match on the full `owner/repo` slug, so a bare repository name selects an empty population and returns a response shaped exactly like a genuine zero-hit search — this field is what separates the two. It matters most in multi-step agentic search, where a zero reads as a normal intermediate result and the mis-specified filter would otherwise never surface. + Bot-authored comments (`sender.login` ending in `[bot]`) and comments shorter than 10 characters (trimmed) are filtered out at ingest time so noise such as `LGTM`, `+1`, or CI chatter does not dilute the retrieval surface. #### Parameters @@ -102,7 +104,7 @@ Bot-authored comments (`sender.login` ending in `[bot]`) and comments shorter th | Name | Type | Description | |------|------|-------------| | `query` | string (optional) | Natural-language query. Omit or empty = scan mode. | -| `repo` | string | Filter by repository (`owner/repo`). | +| `repo` | string | Filter by repository — full slug (`owner/repo`), exact match. A bare repository name matches nothing; search mode reports that as `"repo"` in the response's `filters_unmatched`. | | `state` | `"open"` \| `"closed"` \| `"all"` | Filter by state (default `all`). | | `labels` | string[] | Filter by label names (AND). | | `milestone` | string | Filter by milestone title. | diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index 0ef8984..e9fdf86 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -471,7 +471,13 @@ Returns: - repository、type、state、labels、milestone、assignees、URL、RRF fused score を含む ranked match - 追加 debug フィールド: `dense_score`、`sparse_score`、`dense_rank`、`sparse_rank`、`rerank_score`(rerank 無効時または fallback 時は null) - 同一実体の他の行を吸収した結果には `same_entity`(Entity Aggregation 参照)。`top_k` は行数ではなく実体数で数える -- top-level metadata: `fusion`、`dense_candidates`、`sparse_candidates`、`rerank_requested`、`rerank_applied` +- top-level metadata: `fusion`、`dense_candidates`、`sparse_candidates`、`rerank_requested`、`rerank_applied`、`filters_unmatched` + +**フィルタ不成立(`filters_unmatched`).** `repo` はフルスラッグ(`owner/repo`)の完全一致である——dense 側は Vectorize metadata の `$eq`、sparse 側は `d.repo = ?`。短いリポジトリ名を渡すと1行にもマッチせず、返るレスポンスは「本当にヒットが無かった」場合と同じ形になる。`filters_unmatched` がこの2つを分ける: search mode では常に存在し、`[]` は適用した全フィルタが空でない母集合を選べたこと(つまり `count: 0` は真にヒットゼロ)を意味し、名前が載っていればそのフィルタの母集合が空、すなわち誤っているのはクエリではなくフィルタの値である。 + +区別にフィールドを割く理由は、エージェンティックな多段検索が silent zero のコストを反転させるからである。単発検索ならゼロは呼び出し側が見に行く行き止まりだが、検索ループの中では「この角度には何も無かった」という正常な中間結果として消費されて次へ回る。フィルタ不成立が表に出ないまま、クエリ予算を1回分、偽陰性に使って終わる。 + +判定は存在確認クエリ(`SELECT 1 FROM search_docs WHERE repo = ? LIMIT 1`)で、候補集合が空のときだけ走る——候補が1件でもあればフィルタが成立した証拠なので、追加の読みが hot path に乗ることはない。プローブ自体が失敗した場合は「観測していない不成立」を主張せず、何も報告しない。プローブ対象は `repo` のみ: もっともらしく見える誤値(フルスラッグに対する短いリポジトリ名)が存在するのはこのフィルタだからである。短い名前からフルスラッグへの自動解決は意図的に非スコープ——複数リポジトリにマッチする名前の曖昧解決を設計する必要がある。 **scan mode(query 空).** Vectorize / FTS5 / reranker を経由せず、structured store の recency endpoint から集約する。`since` / `until` は store 側へ push down されるので、窓に行があれば、その窓がどれだけ古くても返る。`since` 省略時の既定は `until` の 7 日前(`until` も省略時は現在の 7 日前)。`until` だけ指定した問い合わせが「下限が上限より新しい空窓」に潰れないための既定である。 diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 443ff98..16261ab 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -473,7 +473,13 @@ Returns: - ranked matches with repository, type, state, labels, milestone, assignees, URL, and RRF fused `score` - additional debug fields per result: `dense_score`, `sparse_score`, `dense_rank`, `sparse_rank`, `rerank_score` (null when rerank disabled or when graceful fallback engaged) - `same_entity` on results that absorbed other rows of the same entity (see Entity Aggregation); `top_k` counts entities, not rows -- top-level metadata: `fusion`, `dense_candidates`, `sparse_candidates`, `rerank_requested`, `rerank_applied` +- top-level metadata: `fusion`, `dense_candidates`, `sparse_candidates`, `rerank_requested`, `rerank_applied`, `filters_unmatched` + +**Unmatched filters (`filters_unmatched`).** `repo` takes the full slug (`owner/repo`) and matches exactly — on the dense side as a Vectorize metadata `$eq`, on the sparse side as `d.repo = ?`. A bare repository name therefore matches no row, and the response that comes back is shaped exactly like a genuine zero-hit search. `filters_unmatched` separates the two: it is always present in search mode, `[]` means every applied filter selected a non-empty population (so `count: 0` really is "no hits"), and a listed name means that filter's population is empty — the value is wrong, not the query. + +The distinction is worth a field because agentic multi-step search inverts the cost of a silent zero. In a single search a zero is a dead end the caller inspects; in a search loop it is a normal intermediate result ("nothing down this angle") that the caller consumes and moves past, so the mis-specified filter never surfaces and one query out of the budget is spent on a false negative. + +The check is an existence probe (`SELECT 1 FROM search_docs WHERE repo = ? LIMIT 1`) run only when the candidate set is empty — a non-empty candidate set already proves the filter matched, so the extra read stays off the hot path. A failed probe reports nothing rather than asserting a mismatch it did not observe. Only `repo` is probed: it is the filter with a plausible-looking wrong value. Resolving a short name to a full slug is deliberately out of scope — that needs an ambiguity design for a name matching several repositories. **Scan mode (empty query).** Vectorize / FTS5 / reranker are skipped and the result set is aggregated from the structured store's recency endpoints. `since` / `until` are pushed down to the store, so a window returns rows whenever it holds rows, however far back it sits. `since` defaults to 7 days before `until` (before now when `until` is omitted), so an `until`-only query does not degenerate into an empty window above its own ceiling. diff --git a/mcp-server/server/tools.js b/mcp-server/server/tools.js index 2c80678..77f1c41 100644 --- a/mcp-server/server/tools.js +++ b/mcp-server/server/tools.js @@ -24,7 +24,10 @@ export const TOOLS = [ "optionally narrow via since / until; " + "(3) doc content fetch — include_content: true inlines raw content on top doc and wiki_doc results. " + "Structured filters (repo, state, labels, milestone, assignee, type) apply across all modes; " + - "type: \"wiki_doc\" narrows to GitHub Wiki pages only. " + + "type: \"wiki_doc\" narrows to GitHub Wiki pages only; repo takes the full slug (owner/repo) and matches " + + "exactly, so a bare repository name selects nothing. In search mode the response carries " + + "filters_unmatched: any filter listed there matched no row in the index at all, which separates a " + + "mis-specified filter from a genuine zero-hit result. " + "Results are aggregated per underlying entity: a file's doc row and its commit diffs are one result, " + "an issue or PR and its comments / reviews are one result. top_k therefore counts distinct entities, " + "and a result that absorbed others carries same_entity { count, others[] } with links to them.", @@ -39,7 +42,10 @@ export const TOOLS = [ }, repo: { type: "string", - description: "Filter by repository (owner/repo)", + description: + "Filter by repository — full slug (owner/repo), exact match. " + + "A bare repository name (\"my-repo\") matches nothing and yields an empty result set; " + + "search mode flags that case as \"repo\" in the response's filters_unmatched.", }, state: { type: "string", diff --git a/mcp-server/test/search-tool-schema.test.js b/mcp-server/test/search-tool-schema.test.js index d02c524..c4f1a3d 100644 --- a/mcp-server/test/search-tool-schema.test.js +++ b/mcp-server/test/search-tool-schema.test.js @@ -47,3 +47,15 @@ test("tool and type descriptions document the wiki surface", () => { assert.match(search.description, /wiki/i); assert.match(typeParam.description, /wiki_doc/); }); + +// gh#219: the proxy schema is the description a client actually reads, so the +// exact-match requirement on `repo` has to be stated here — a bare repository +// name silently selects nothing, and the caller has no way to see that from the +// zero-result response alone. +test("repo description states the full-slug exact match and the unmatched-filter signal", () => { + const repoParam = search?.inputSchema?.properties?.repo; + assert.ok(repoParam, "repo param is present in the mirrored schema"); + assert.match(repoParam.description, /owner\/repo/); + assert.match(repoParam.description, /exact match/i); + assert.match(repoParam.description, /filters_unmatched/); +}); diff --git a/src/fts.test.ts b/src/fts.test.ts index 8fbd172..180912b 100644 --- a/src/fts.test.ts +++ b/src/fts.test.ts @@ -4,6 +4,7 @@ import { reciprocalRankFusion, escapeFtsQuery, tokenizerKindForType, + detectUnmatchedFilters, } from "./fts.js"; describe("fts: tokenizerKindForType", () => { @@ -114,3 +115,63 @@ describe("fts: reciprocalRankFusion", () => { expect(reciprocalRankFusion({ rankers: new Map([["dense", new Map()]]) })).toEqual([]); }); }); + +// Issue #219. Two searches that both return zero results are indistinguishable +// to the caller: one found nothing, the other filtered on a repo slug that +// selects no rows at all. `detectUnmatchedFilters` is what makes the responses +// differ, so the test pins the decision, not the SQL (the SQL is exercised +// against a real D1 in fts.workers.test.ts). +describe("fts: detectUnmatchedFilters (#219)", () => { + /** Minimal D1 stand-in: records probes, answers from a fixed repo set. */ + function fakeDb(indexedRepos: string[], opts: { throws?: boolean } = {}) { + const probes: string[] = []; + const db = { + probes, + prepare() { + return { + bind(repo: string) { + probes.push(repo); + return { + first: async () => { + if (opts.throws) throw new Error("D1_ERROR: unreachable"); + return indexedRepos.includes(repo) ? { present: 1 } : null; + }, + }; + }, + }; + }, + }; + return db as typeof db & D1Database; + } + + it("flags repo when the filter value selects no indexed row", async () => { + const db = fakeDb(["Liplus-Project/liplus-language"]); + // Bare repository name — the exact-match filter yields an empty population. + expect(await detectUnmatchedFilters(db, { repo: "liplus-language" }, 0)).toEqual(["repo"]); + expect(db.probes).toEqual(["liplus-language"]); + }); + + it("stays empty for a genuine zero-hit search on a repo that exists", async () => { + const db = fakeDb(["Liplus-Project/liplus-language"]); + expect( + await detectUnmatchedFilters(db, { repo: "Liplus-Project/liplus-language" }, 0), + ).toEqual([]); + }); + + it("does not probe when candidates exist (the filter provably matched)", async () => { + const db = fakeDb([]); + expect(await detectUnmatchedFilters(db, { repo: "anything" }, 7)).toEqual([]); + expect(db.probes).toEqual([]); + }); + + it("does not probe when no repo filter was applied", async () => { + const db = fakeDb([]); + expect(await detectUnmatchedFilters(db, {}, 0)).toEqual([]); + expect(db.probes).toEqual([]); + }); + + it("reports nothing when the probe itself fails (never assert an unobserved mismatch)", async () => { + const db = fakeDb([], { throws: true }); + expect(await detectUnmatchedFilters(db, { repo: "owner/repo" }, 0)).toEqual([]); + }); +}); diff --git a/src/fts.ts b/src/fts.ts index da034f7..e80ee0c 100644 --- a/src/fts.ts +++ b/src/fts.ts @@ -249,6 +249,69 @@ export async function deleteFtsRow( .run(); } +/** + * True when at least one indexed row carries this exact `repo` value. + * + * Existence probe behind the `filters_unmatched` observability field (issue #219). + * The `repo` filter is an exact match on the full slug (`owner/repo`) — a bare + * repository name matches no row, and the resulting empty candidate set is shaped + * exactly like a genuine zero-hit search. The caller runs this probe only on that + * ambiguous shape, so the extra D1 read never sits on the hot path. + * + * `LIMIT 1` on the indexed `repo` column, no FTS5 involvement: the query stops at + * the first matching row instead of counting the population. + */ +export async function repoHasIndexedRows( + db: D1Database, + repo: string, +): Promise { + const row = await db + .prepare(`SELECT 1 AS present FROM search_docs WHERE repo = ? LIMIT 1`) + .bind(repo) + .first<{ present: number }>(); + return row != null; +} + +/** + * Names of the applied filters whose selected population is empty (issue #219). + * + * Answers the question the candidate count cannot: "did the search find nothing, + * or did the filter select nothing?" Both produce `count: 0`, and a caller doing + * multi-step agentic search reads a zero as a normal intermediate result + * ("nothing down this angle") and moves on — so a mis-specified filter is + * consumed as a false negative instead of being noticed. + * + * Rules the shape of the check: + * - probe only when `candidateCount === 0`. A non-empty candidate set proves + * every applied filter matched, so the extra D1 read stays off the hot path. + * - a probe failure is not a finding. An unreachable D1 yields an empty list + * (the safer direction: never assert a mismatch that was not observed). + * + * `repo` is the only filter probed. It is the one where a plausible-looking wrong + * value exists — the bare repository name against the required `owner/repo` slug. + * `state` / `type` are enum-constrained, and `milestone` / `assignee` do not have + * a comparable near-miss form. + */ +export async function detectUnmatchedFilters( + db: D1Database, + filters: { repo?: string }, + candidateCount: number, +): Promise { + const unmatched: string[] = []; + if (candidateCount > 0) return unmatched; + if (filters.repo) { + try { + if (!(await repoHasIndexedRows(db, filters.repo))) unmatched.push("repo"); + } catch (err) { + console.error( + "detectUnmatchedFilters: repo probe failed:", + err instanceof Error ? err.message : String(err), + ); + } + } + return unmatched; +} + /** Hit returned by FTS5 BM25 query. `score` is the raw bm25() value (lower = better). */ export interface FtsHit { vectorId: string; diff --git a/src/fts.workers.test.ts b/src/fts.workers.test.ts index 9a4140e..045a793 100644 --- a/src/fts.workers.test.ts +++ b/src/fts.workers.test.ts @@ -3,6 +3,7 @@ import { env, applyD1Migrations } from "cloudflare:test"; import { upsertFtsRow, queryFts, + repoHasIndexedRows, deleteFtsRow, backfillNatSegments, tokenizerKindForType, @@ -589,6 +590,42 @@ describe("fts D1: structured filters", () => { }); }); +// Issue #219: an unmatched `repo` filter and a genuine zero-hit search produce +// the same empty candidate set, so the response cannot be told apart by the +// caller. `repoHasIndexedRows` is the probe that separates them — it is what +// makes `filters_unmatched` in the search response answerable. +describe("fts D1: repoHasIndexedRows (filters_unmatched probe)", () => { + it("separates an unmatched repo filter from a genuine zero-hit search", async () => { + const repo = "t/repo-probe"; + await upsertFtsRow( + env.DB_FTS, + mkRow({ vectorId: "i:repo-probe", type: "issue", repo, content: "indexed probe subject" }), + ); + + // Both queries return zero hits — indistinguishable on the hit set alone. + const wrongSlug = await queryFts(env.DB_FTS, "indexed", 10, { repo: "repo-probe" }); + const noSuchTerm = await queryFts(env.DB_FTS, "unrelatedtermnowhere", 10, { repo }); + expect(wrongSlug).toEqual([]); + expect(noSuchTerm).toEqual([]); + + // The probe is what tells them apart: the bare name selects no population, + // the full slug selects one that simply held no match for the query. + expect(await repoHasIndexedRows(env.DB_FTS, "repo-probe")).toBe(false); + expect(await repoHasIndexedRows(env.DB_FTS, repo)).toBe(true); + }); + + it("matches the full slug exactly (no prefix or suffix match)", async () => { + const repo = "t/repo-exact"; + await upsertFtsRow( + env.DB_FTS, + mkRow({ vectorId: "i:repo-exact", type: "issue", repo, content: "exact match subject" }), + ); + expect(await repoHasIndexedRows(env.DB_FTS, "t/repo-exac")).toBe(false); + expect(await repoHasIndexedRows(env.DB_FTS, "t/repo-exact-extra")).toBe(false); + expect(await repoHasIndexedRows(env.DB_FTS, "t/repo-exact")).toBe(true); + }); +}); + describe("fts D1: queryFts edge cases", () => { it("returns [] for an empty / whitespace query (no MATCH)", async () => { const repo = "t/empty"; diff --git a/src/mcp.ts b/src/mcp.ts index 46d2e1f..1ae93f2 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -25,6 +25,7 @@ import type { import type { GitHubUserProps } from "./oauth.js"; import { queryFts, + detectUnmatchedFilters, toRankMap, reciprocalRankFusion, type FtsHit, @@ -219,7 +220,10 @@ export class RagMcpAgentV2 extends McpAgent { "optionally narrow via since / until to list recent activity across every type.\n" + " 3. Doc content fetch: pass include_content: true to inline the raw file content of top doc and wiki_doc results " + "(docs via GitHub Contents API, wiki_docs via raw.githubusercontent.com/wiki; capped at the first few rows of each).\n" + - "Optional metadata filters (repo, state, labels, milestone, assignee, type) apply across all modes. " + + "Optional metadata filters (repo, state, labels, milestone, assignee, type) apply across all modes; " + + "repo takes the full slug (owner/repo) and matches exactly, so a bare repository name selects nothing. " + + "In search mode the response carries filters_unmatched: any filter listed there matched no row in the " + + "index at all, which separates a mis-specified filter from a genuine zero-hit result. " + "Use type: \"doc\" for repository docs (files in /docs/ etc.) and type: \"wiki_doc\" for GitHub Wiki pages — " + "both surfaces co-exist and a same-name page in both is returned as two separate hits. " + "Use type: \"diff\" to retrieve judgment history preserved in commit diffs — including changes to deleted files " + @@ -241,7 +245,11 @@ export class RagMcpAgentV2 extends McpAgent { repo: z .string() .optional() - .describe("Filter by repository (owner/repo)"), + .describe( + "Filter by repository — full slug (owner/repo), exact match. " + + "A bare repository name (\"my-repo\") matches nothing and yields an empty result set; " + + "search mode flags that case as \"repo\" in the response's filters_unmatched.", + ), state: z .enum(["open", "closed", "all"]) .optional() @@ -537,6 +545,23 @@ export class RagMcpAgentV2 extends McpAgent { }; } + // ── Filter-match observability (issue #219) ────────────── + // `repo` filters on both sides by exact match on the full slug — + // Vectorize metadata `$eq` on the dense side, `d.repo = ?` on the sparse + // side — so a bare repository name empties BOTH candidate sets at once. + // The combined count is therefore the right probe condition; the check + // itself (and why it never asserts on a failed probe) lives in + // `detectUnmatchedFilters`. + // + // Deliberately observability only: the short slug is NOT resolved to a + // full one. Doing that needs an ambiguity design for a prefix matching + // several repositories, which is a heavier change (issue #219 non-scope). + const filtersUnmatched = await detectUnmatchedFilters( + this.env.DB_FTS, + { repo }, + denseResult.hits.length + sparseHits.length, + ); + // ── Fusion: build rank maps and combine via RRF ────────── // Both hit arrays are already ordered best-first by their respective ranker. // For dense_only / sparse_only, RRF degenerates to a single-ranker sort, @@ -1099,6 +1124,14 @@ export class RagMcpAgentV2 extends McpAgent { sort: effectiveSort, dense_candidates: denseResult.hits.length, sparse_candidates: sparseHits.length, + // Filters that matched no row in the index at all (issue #219). + // Always present; `[]` means every applied filter matched + // something, so `count: 0` is a genuine zero-hit result. A + // listed filter means the population it selects is empty — + // the value is wrong (for `repo`, typically a bare repository + // name where the full `owner/repo` slug is required), not the + // query. Only checked when the candidate set is empty. + filters_unmatched: filtersUnmatched, // rerank metadata: // - rerank_requested: caller-facing flag (default true) // - rerank_applied: whether the cross-encoder actually