From 01e4d0a62482d40dd8163282dd02cab0dc0f6295 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 2 Aug 2026 09:49:55 -0700 Subject: [PATCH 1/6] Expose kind and entity filters on knowledge search (CL-5021) hybridSearch already filtered by document kind and entity id; the plane's search() and the HTTP search route never surfaced those params, so no in-process or HTTP caller could reach them. Thread kinds/entityIds through KnowledgeSearchParams and accept kinds/ entity_ids on POST /api/knowledge/search. No filter = unchanged behavior. --- IMPLEMENTATION.md | 2 +- PRODUCT.md | 2 +- src/knowledge.test.ts | 33 +++++++++++++++++++++++++ src/knowledge.ts | 6 +++++ src/routes/routes.test.ts | 51 ++++++++++++++++++++++++++++++++++++--- src/routes/search.ts | 6 ++++- 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 201173d..2da9de3 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -477,7 +477,7 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | Method + path | Grant action | Request body | Response | |---|---|---|---| | `POST /api/knowledge/capture` | `capture` | `{ title, text, acl? }` | `200 { status: "captured" }`; `400` on validation | -| `POST /api/knowledge/search` | `search` | `{ query, k? }` (k 1–50) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | +| `POST /api/knowledge/search` | `search` | `{ query, k?, kinds?, entity_ids? }` (k 1–50; `kinds`/`entity_ids` narrow to a document `kind` or linked entity id, unset = unfiltered) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | | `GET /api/knowledge/timeline` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope (`last_seen_at` DESC), filtered with the same visibility SQL + `acl_block` post-filter as search. One event per document (active live version), not per capture attempt. See wire field notes below. | `mountKnowledgeRoutes` and `mountKnowledgeEngine` both mount the three HTTP routes. MCP is a separate package (`@corbitsdev/hono-openapi-mcp`). diff --git a/PRODUCT.md b/PRODUCT.md index c7b753b..0a3079c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -75,7 +75,7 @@ for a user must check the capability themselves (see README). ```http POST /api/knowledge/capture { "title", "text", "acl?" } -POST /api/knowledge/search { "query", "k?" } +POST /api/knowledge/search { "query", "k?", "kinds?", "entity_ids?" } GET /api/knowledge/timeline ``` diff --git a/src/knowledge.test.ts b/src/knowledge.test.ts index 6bc4ba1..f92a2ec 100644 --- a/src/knowledge.test.ts +++ b/src/knowledge.test.ts @@ -255,6 +255,39 @@ describe("createKnowledgePlane.search — ACL post-filter wiring", () => { await plane.close(); }); + it("threads kinds and entityIds through to hybridSearch", async () => { + // Regression guard for CL-5021: the plane must pass kinds/entityIds + // straight through to the service that already supports them, not + // silently drop them. + hybridSearch.mockClear(); + hybridSearch.mockImplementation(() => + Promise.resolve({ hits: [], evidence: "none" as const }), + ); + sql.mockClear(); + + const { createKnowledgePlane: makePlane } = await import( + `./knowledge.ts?wiring-kinds=${Date.now()}` + ); + const plane = makePlane(wiringConfig); + await plane.search({ + tenantId: TENANT, + principalId: PRINCIPAL, + query: "q", + kinds: ["artifact", "task"], + entityIds: ["e1"], + }); + + expect(hybridSearch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + kinds: ["artifact", "task"], + entityIds: ["e1"], + }), + ); + + await plane.close(); + }); + it("withholds a hit whose acl_block is unreadable (fail-closed wiring)", async () => { // Non-string/non-array acl_block is the case this PR closed: the post-filter // must remove the hit, not pass it through. diff --git a/src/knowledge.ts b/src/knowledge.ts index 4ca32d3..39ca7f6 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -59,6 +59,10 @@ export type KnowledgeIdentity = { export type KnowledgeSearchParams = KnowledgeIdentity & { query: string; k?: number; + /** Restrict to documents whose `kind` is one of these. Unset searches every kind. */ + kinds?: string[]; + /** Restrict to documents linked to one of these entity ids. Unset applies no entity filter. */ + entityIds?: string[]; }; export type KnowledgeAskParams = KnowledgeIdentity & { @@ -279,6 +283,8 @@ export function createKnowledgePlane( principalId: params.principalId, query: params.query, k: params.k, + kinds: params.kinds, + entityIds: params.entityIds, }); // Block-list post-filter: docs may store acl_block as a list of diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 71d4d33..725c807 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -39,9 +39,15 @@ function stubPlane(opts?: { }) { const captured: { title: string; tenantId: string; principalId: string }[] = []; + const searched: Array< + Pick[0], "kinds" | "entityIds" | "k"> + > = []; const catalog = opts?.timelineCatalog ?? []; const plane: KnowledgePlane = { - search: async () => ({ hits: [], evidence: "none" }), + search: async (p) => { + searched.push({ kinds: p.kinds, entityIds: p.entityIds, k: p.k }); + return { hits: [], evidence: "none" }; + }, ask: async () => ({ text: "", citations: [], evidence: "none" }), capture: async (p) => { captured.push({ @@ -61,7 +67,7 @@ function stubPlane(opts?: { }, close: async () => {}, }; - return { plane, captured }; + return { plane, captured, searched }; } function buildApp( @@ -73,7 +79,7 @@ function buildApp( principalId?: string; }, ) { - const { plane, captured } = stubPlane(opts); + const { plane, captured, searched } = stubPlane(opts); const grantConfig = { grantStore: createInMemoryGrantStore(grants), conditionRegistry: {}, @@ -111,7 +117,7 @@ function buildApp( await next(); }); mountKnowledgeRoutes(app, deps); - return { app, captured }; + return { app, captured, searched }; } // Mirrors a host that mounts the knowledge routes outside the tenant prefix @@ -224,6 +230,43 @@ describe("knowledge HTTP routes", () => { expect(res.status).toBe(400); }); + test("search threads kinds and entity_ids through to the plane", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ + query: "hello", + kinds: ["artifact", "task"], + entity_ids: ["e1", "e2"], + }), + ); + expect(res.status).toBe(200); + expect(searched).toEqual([ + { kinds: ["artifact", "task"], entityIds: ["e1", "e2"], k: undefined }, + ]); + }); + + test("search with no kinds/entity_ids leaves them unset on the plane call", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ query: "hello" }), + ); + expect(res.status).toBe(200); + expect(searched).toEqual([ + { kinds: undefined, entityIds: undefined, k: undefined }, + ]); + }); + + test("search rejects a non-string-array kinds (400)", async () => { + const { app } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ query: "hi", kinds: [1, 2] }), + ); + expect(res.status).toBe(400); + }); + test("timeline requires the search grant", async () => { const { app } = buildApp([grant(PRINCIPAL, "capture")]); const res = await app.request("/api/knowledge/timeline"); diff --git a/src/routes/search.ts b/src/routes/search.ts index d1ef807..96e39e1 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -12,6 +12,8 @@ import { caller, grantGuard, requirePrincipal } from "./deps.ts"; const SearchRequest = type({ query: "string >= 1", "k?": "1 <= number.integer <= 50", + "kinds?": "string[]", + "entity_ids?": "string[]", }); export function mountSearchRoute(app: Hono, deps: RouteDeps): void { @@ -36,7 +38,7 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { grantGuard(deps, "search"), validator("json", SearchRequest), async (c) => { - const { query, k } = c.req.valid("json"); + const { query, k, kinds, entity_ids } = c.req.valid("json"); const { scopeId, subjectId } = caller(c); try { const result = await deps.knowledge.search({ @@ -44,6 +46,8 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { tenantId: scopeId, principalId: subjectId, ...(k !== undefined ? { k } : {}), + ...(kinds !== undefined ? { kinds } : {}), + ...(entity_ids !== undefined ? { entityIds: entity_ids } : {}), }); return c.json(result); } catch (err) { From dee12310760016ae6a932b4080d25de6ec9e2d0e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 2 Aug 2026 09:59:16 -0700 Subject: [PATCH 2/6] Document lexical-only scope and empty-array semantics for search filters (CL-5021) Review found the kinds/entityIds wording promised an exact post-fusion guarantee the implementation doesn't provide: hybridSearch's dense/ semantic channel has no kind or entity predicate, so a fused hit can surface a document that doesn't match the requested filter. Making the dense channel filter-aware is a materially larger, untestable- without-a-live-Postgres change (raw SQL query shape, hnsw/index implications) than this ticket's scope, so instead every place the filter is documented now says plainly: these narrow the lexical leg only, and callers needing an exact guarantee must filter hits themselves. Also documents that an empty array on either filter is equivalent to omitting it (no filter, not "match nothing"), including for the empty-query-requires-a-structured-filter check in hybridSearch, and adds a route test pinning that [] passes through unchanged rather than being silently dropped. --- IMPLEMENTATION.md | 2 +- PRODUCT.md | 7 +++++++ src/knowledge.ts | 14 ++++++++++++-- src/routes/routes.test.ts | 10 ++++++++++ src/routes/search.ts | 15 +++++++++++++++ src/services/search.ts | 23 +++++++++++++++++++++++ 6 files changed, 68 insertions(+), 3 deletions(-) diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 2da9de3..0ecadb1 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -477,7 +477,7 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | Method + path | Grant action | Request body | Response | |---|---|---|---| | `POST /api/knowledge/capture` | `capture` | `{ title, text, acl? }` | `200 { status: "captured" }`; `400` on validation | -| `POST /api/knowledge/search` | `search` | `{ query, k?, kinds?, entity_ids? }` (k 1–50; `kinds`/`entity_ids` narrow to a document `kind` or linked entity id, unset = unfiltered) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | +| `POST /api/knowledge/search` | `search` | `{ query, k?, kinds?, entity_ids? }` (k 1–50; `kinds`/`entity_ids` narrow the LEXICAL retrieval leg to a document `kind` or linked entity id — the dense/semantic leg has no such predicate, so a fused hit can still surface without matching; unset or `[]` = unfiltered) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | | `GET /api/knowledge/timeline` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope (`last_seen_at` DESC), filtered with the same visibility SQL + `acl_block` post-filter as search. One event per document (active live version), not per capture attempt. See wire field notes below. | `mountKnowledgeRoutes` and `mountKnowledgeEngine` both mount the three HTTP routes. MCP is a separate package (`@corbitsdev/hono-openapi-mcp`). diff --git a/PRODUCT.md b/PRODUCT.md index 0a3079c..8a901ec 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -82,6 +82,13 @@ GET /api/knowledge/timeline Requests are authenticated upstream by Interchange (however the host chose); the SDK routes assume a resolved principal on the context. +`kinds`/`entity_ids` narrow the lexical (keyword) leg of search only — the +dense/semantic leg has no such predicate. A document can still appear in +results without matching the requested kind/entity if it was surfaced purely +by semantic similarity; treat these as a relevance hint, not an exact +post-fusion filter. An empty array on either field is equivalent to omitting +it (no filter), not "match nothing". + ### Document ACL (who may surface on search/timeline) — set at capture Optional on capture; default is scope-wide (the company brain), or diff --git a/src/knowledge.ts b/src/knowledge.ts index 39ca7f6..7b6c887 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -59,9 +59,19 @@ export type KnowledgeIdentity = { export type KnowledgeSearchParams = KnowledgeIdentity & { query: string; k?: number; - /** Restrict to documents whose `kind` is one of these. Unset searches every kind. */ + /** + * Narrows the LEXICAL retrieval leg to documents whose `kind` is one of + * these — see `hybridSearch` in services/search.ts. The dense/semantic + * channel has no kind predicate, so a hit that surfaces only through + * dense similarity can still appear in results with a different `kind`; + * this is a scoping hint, not a guarantee every hit matches. Unset or an + * empty array both mean "no filter" (equivalent, not "match nothing"). + */ kinds?: string[]; - /** Restrict to documents linked to one of these entity ids. Unset applies no entity filter. */ + /** + * Same lexical-only scoping as `kinds`, restricted to documents linked to + * one of these entity ids. Unset or an empty array both mean "no filter". + */ entityIds?: string[]; }; diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 725c807..91ed914 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -267,6 +267,16 @@ describe("knowledge HTTP routes", () => { expect(res.status).toBe(400); }); + test("search passes an EMPTY kinds/entity_ids array through unchanged — the route does not collapse [] to absent; hybridSearch treats both the same way (see services/search.ts)", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ query: "hello", kinds: [], entity_ids: [] }), + ); + expect(res.status).toBe(200); + expect(searched).toEqual([{ kinds: [], entityIds: [], k: undefined }]); + }); + test("timeline requires the search grant", async () => { const { app } = buildApp([grant(PRINCIPAL, "capture")]); const res = await app.request("/api/knowledge/timeline"); diff --git a/src/routes/search.ts b/src/routes/search.ts index 96e39e1..956ec99 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -9,6 +9,15 @@ import { SearchResponseSchema } from "../core/schemas/search.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; +// `kinds`/`entity_ids` scope the LEXICAL retrieval leg only — the dense +// channel has no equivalent predicate, so fused results can still include a +// hit that reached the result purely via semantic similarity and does not +// match the requested kind/entity. See the `kinds`/`entityIds` doc comments +// on KnowledgeSearchParams (knowledge.ts) for the full explanation. +// +// An empty array on either field is equivalent to omitting it — "no filter" +// — not "match nothing", and does not satisfy the requirement that an empty +// `query` be paired with a non-empty structured filter. const SearchRequest = type({ query: "string >= 1", "k?": "1 <= number.integer <= 50", @@ -22,6 +31,12 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { describeRoute({ tags: ["knowledge"], summary: "Hybrid semantic + keyword search", + description: + "`kinds`/`entity_ids` scope the lexical (keyword) retrieval leg " + + "only; the dense/semantic leg has no such predicate, so a hit " + + "surfaced purely by semantic similarity can appear in results " + + "without matching the requested kind or entity. Treat these as a " + + "relevance hint, not an exact post-fusion filter.", responses: { 200: { description: "Ranked hits with evidence", diff --git a/src/services/search.ts b/src/services/search.ts index aa7af61..a19d518 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -361,7 +361,11 @@ interface LexicalCandidateParams { query: string; ftsLanguage: string; overfetchLimit: number; + // Applied ONLY to this (lexical) candidate query. An empty array is + // treated identically to `undefined` — no filter — never "match nothing". kinds?: string[] | undefined; + // Applied ONLY to this (lexical) candidate query. An empty array is + // treated identically to `undefined` — no filter — never "match nothing". entityIds?: string[] | undefined; // Defaults to 'live' — every version-scoped query filters by generation so // a replayed generation's chunks never leak into a live search and vice @@ -750,7 +754,18 @@ export interface HybridSearchArgs { tenantId: string; principalId: string | null; k?: number | undefined; + // Constrains the LEXICAL channel only (see fetchLexicalCandidates) — the + // dense/semantic channel (fetchDenseCandidates) takes no kind/entity + // predicate and is unaffected. Fusion then merges both channels, so a hit + // that reached the result purely through the dense channel is NOT + // guaranteed to match `kinds`; this is a scoping hint on the lexical leg + // of retrieval, not an exact post-fusion filter over the final hit set. + // An empty array is treated identically to `undefined` (no filter) — it + // does NOT count as "provided" for the empty-query-requires-a-structured- + // filter check below. kinds?: string[] | undefined; + // Same lexical-channel-only scoping and empty-array-equals-absent + // semantics as `kinds`, above. entityIds?: string[] | undefined; // Defaults to 'live' — the normal capture/search behavior. A non-live // generation (a transform_run id, the replay pipeline) searches that replay's corpus @@ -780,6 +795,14 @@ export interface HybridSearchResult { * budget for the document, so the request was skipped rather than sent * guaranteed to exceed the model's token limit (see `RerankQueryTooLongError` * in rerank-client.ts). + * + * `kinds`/`entityIds` narrow the LEXICAL channel's candidate query only + * (see `HybridSearchArgs`). The dense channel has no equivalent predicate, + * so a document that doesn't match the requested kind/entity can still + * appear in the fused result if the dense channel surfaced it on semantic + * similarity. Callers that need an exact post-fusion guarantee must filter + * `hits` themselves; this is a retrieval-time scoping hint, not a hard + * result-set constraint. */ export async function hybridSearch( deps: HybridSearchDeps, From 6334d61364ac688efc800106595f8aa1a922d063 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 2 Aug 2026 10:16:41 -0700 Subject: [PATCH 3/6] Apply kind/entity filters to the dense channel too (CL-5021) kinds/entityIds only reached fetchLexicalCandidates; fetchDenseCandidates had no equivalent predicate, so a document that didn't match the caller's filter could still surface through the dense leg once RRF fusion merged both channels. Push the same predicate into the dense query (kd.kind = ANY(...) and a knowledge_edge subquery for entityIds, bound the same way fetchChunkVectors already binds a text[] param) so every fused hit matches the requested filter regardless of which channel found it. Updates the docs/comments that previously called this out as a known limitation. --- IMPLEMENTATION.md | 2 +- PRODUCT.md | 10 +-- src/knowledge.ts | 14 ++- src/routes/search.ts | 16 ++-- src/services/search.test.ts | 171 ++++++++++++++++++++++++++++++++++++ src/services/search.ts | 63 +++++++++---- 6 files changed, 233 insertions(+), 43 deletions(-) diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 0ecadb1..c5e4cde 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -477,7 +477,7 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | Method + path | Grant action | Request body | Response | |---|---|---|---| | `POST /api/knowledge/capture` | `capture` | `{ title, text, acl? }` | `200 { status: "captured" }`; `400` on validation | -| `POST /api/knowledge/search` | `search` | `{ query, k?, kinds?, entity_ids? }` (k 1–50; `kinds`/`entity_ids` narrow the LEXICAL retrieval leg to a document `kind` or linked entity id — the dense/semantic leg has no such predicate, so a fused hit can still surface without matching; unset or `[]` = unfiltered) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | +| `POST /api/knowledge/search` | `search` | `{ query, k?, kinds?, entity_ids? }` (k 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | | `GET /api/knowledge/timeline` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope (`last_seen_at` DESC), filtered with the same visibility SQL + `acl_block` post-filter as search. One event per document (active live version), not per capture attempt. See wire field notes below. | `mountKnowledgeRoutes` and `mountKnowledgeEngine` both mount the three HTTP routes. MCP is a separate package (`@corbitsdev/hono-openapi-mcp`). diff --git a/PRODUCT.md b/PRODUCT.md index 8a901ec..a24643b 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -82,12 +82,10 @@ GET /api/knowledge/timeline Requests are authenticated upstream by Interchange (however the host chose); the SDK routes assume a resolved principal on the context. -`kinds`/`entity_ids` narrow the lexical (keyword) leg of search only — the -dense/semantic leg has no such predicate. A document can still appear in -results without matching the requested kind/entity if it was surfaced purely -by semantic similarity; treat these as a relevance hint, not an exact -post-fusion filter. An empty array on either field is equivalent to omitting -it (no filter), not "match nothing". +`kinds`/`entity_ids` narrow both the lexical and dense/semantic legs of +search before results are fused, so every hit matches the requested +kind/entity. An empty array on either field is equivalent to omitting it (no +filter), not "match nothing". ### Document ACL (who may surface on search/timeline) — set at capture diff --git a/src/knowledge.ts b/src/knowledge.ts index 7b6c887..3d831c5 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -60,17 +60,15 @@ export type KnowledgeSearchParams = KnowledgeIdentity & { query: string; k?: number; /** - * Narrows the LEXICAL retrieval leg to documents whose `kind` is one of - * these — see `hybridSearch` in services/search.ts. The dense/semantic - * channel has no kind predicate, so a hit that surfaces only through - * dense similarity can still appear in results with a different `kind`; - * this is a scoping hint, not a guarantee every hit matches. Unset or an - * empty array both mean "no filter" (equivalent, not "match nothing"). + * Narrows every retrieval channel to documents whose `kind` is one of + * these — see `hybridSearch` in services/search.ts. Applied before fusion, + * so a fused hit is always guaranteed to match. Unset or an empty array + * both mean "no filter" (equivalent, not "match nothing"). */ kinds?: string[]; /** - * Same lexical-only scoping as `kinds`, restricted to documents linked to - * one of these entity ids. Unset or an empty array both mean "no filter". + * Same scoping as `kinds`, restricted to documents linked to one of these + * entity ids. Unset or an empty array both mean "no filter". */ entityIds?: string[]; }; diff --git a/src/routes/search.ts b/src/routes/search.ts index 956ec99..f9b679e 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -9,11 +9,9 @@ import { SearchResponseSchema } from "../core/schemas/search.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; -// `kinds`/`entity_ids` scope the LEXICAL retrieval leg only — the dense -// channel has no equivalent predicate, so fused results can still include a -// hit that reached the result purely via semantic similarity and does not -// match the requested kind/entity. See the `kinds`/`entityIds` doc comments -// on KnowledgeSearchParams (knowledge.ts) for the full explanation. +// `kinds`/`entity_ids` scope every retrieval channel — see the +// `kinds`/`entityIds` doc comments on KnowledgeSearchParams (knowledge.ts) +// for the full explanation. // // An empty array on either field is equivalent to omitting it — "no filter" // — not "match nothing", and does not satisfy the requirement that an empty @@ -32,11 +30,9 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { tags: ["knowledge"], summary: "Hybrid semantic + keyword search", description: - "`kinds`/`entity_ids` scope the lexical (keyword) retrieval leg " + - "only; the dense/semantic leg has no such predicate, so a hit " + - "surfaced purely by semantic similarity can appear in results " + - "without matching the requested kind or entity. Treat these as a " + - "relevance hint, not an exact post-fusion filter.", + "`kinds`/`entity_ids` scope every retrieval channel (lexical and " + + "dense) before results are fused, so every hit matches the " + + "requested kind/entity.", responses: { 200: { description: "Ranked hits with evidence", diff --git a/src/services/search.test.ts b/src/services/search.test.ts index 54fb27d..97384c5 100644 --- a/src/services/search.test.ts +++ b/src/services/search.test.ts @@ -355,3 +355,174 @@ describe("fetchDenseCandidates hnsw tuning", () => { await expect(fetchDenseCandidates(args(fake.rawSql))).rejects.toThrow("connection reset"); }); }); + +// Regression coverage for the fusion-bypass bug: kinds/entityIds used to be +// applied only to fetchLexicalCandidates, so a document that didn't match +// the caller's filter could still reach the caller through the dense +// channel once RRF fusion merged both result sets. This exercises +// fetchDenseCandidates directly with a fake postgres handle that behaves +// like a real one WOULD for the query fetchDenseCandidates builds: it reads +// the actual SQL text and bound params off the call and only returns rows +// that satisfy whatever kind/entity predicate is (or isn't) present. If the +// implementation stopped sending the predicate to the dense query, this +// fake would fall back to returning every row — unfiltered, exactly like a +// live Postgres would with no WHERE clause — and the assertions below +// would fail. +describe("fetchDenseCandidates kind/entity filtering", () => { + const MODEL_ROW = { model_key: "bbbbbbbbbbbbbbbb", model_id: "m", dims: 768 }; + + // Two chunks the ANN scan would surface on pure semantic similarity: one + // belongs to a document of kind "task" linked to entity "e-match", the + // other to kind "note" linked to no requested entity. A caller filtering + // by kinds: ["task"] or entityIds: ["e-match"] must never see "chunk-note". + const DENSE_ROWS: Array> = [ + { + chunk_id: "chunk-task", + document_id: "doc-task", + version_id: "ver-task", + version: 1, + status: "active", + title: "Task doc", + kind: "task", + adapter: "artifact", + external_ref: "artifact:task", + created_by_kind: "human", + generator_agent_id: null, + snippet_text: "matches on kind and entity", + occurred_at: new Date("2026-01-01T00:00:00Z").toISOString(), + authority: 0.5, + }, + { + chunk_id: "chunk-note", + document_id: "doc-note", + version_id: "ver-note", + version: 1, + status: "active", + title: "Note doc", + kind: "note", + adapter: "artifact", + external_ref: "artifact:note", + created_by_kind: "human", + generator_agent_id: null, + snippet_text: "surfaced purely by semantic similarity", + occurred_at: new Date("2026-01-01T00:00:00Z").toISOString(), + authority: 0.5, + }, + ]; + + // doc-task is linked to entity "e-match"; doc-note is linked to nothing. + const ENTITY_LINKS: Record = { + "doc-task": ["e-match"], + "doc-note": [], + }; + + function openaiEmbedFetch(): typeof fetch { + return (() => + Promise.resolve( + new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2] }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + )) as unknown as typeof fetch; + } + + // Behaves like a real Postgres connection would for exactly the queries + // fetchDenseCandidates issues: model-registry lookup, then the dense + // SELECT itself, evaluating whatever kind/entity predicate the SQL text + // actually contains against the canned dataset above. + function fakeRawSql() { + type FakeTx = { + unsafe: (sqlText: string, params?: unknown[]) => Promise; + savepoint: (fn: (sp: FakeTx) => Promise) => Promise; + }; + function evaluate(sqlText: string, params: unknown[]): unknown[] { + let rows = DENSE_ROWS; + const kindMatch = sqlText.match(/kd\.kind = ANY\(\$(\d+)/); + if (kindMatch) { + const kinds = params[Number(kindMatch[1]) - 1] as string[]; + rows = rows.filter((r) => kinds.includes(r["kind"] as string)); + } + const entityMatch = sqlText.match(/ke\.to_ref = ANY\(\$(\d+)/); + if (entityMatch) { + const entityIds = params[Number(entityMatch[1]) - 1] as string[]; + rows = rows.filter((r) => + (ENTITY_LINKS[r["document_id"] as string] ?? []).some((e) => + entityIds.includes(e), + ), + ); + } + return rows; + } + const tx: FakeTx = { + unsafe: (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes("ORDER BY")) { + return Promise.resolve(evaluate(sqlText, params)); + } + return Promise.resolve([]); + }, + savepoint: (fn: (sp: FakeTx) => Promise) => fn(tx), + }; + const rawSql = { + unsafe: (sqlText: string) => + Promise.resolve( + sqlText.includes("FROM knowledge_embed_model") ? [MODEL_ROW] : [], + ), + begin: (cb: (t: FakeTx) => Promise) => cb(tx), + }; + return rawSql as unknown as Parameters[0]["sql"]; + } + + function baseArgs(sql: Parameters[0]["sql"]) { + return { + sql, + embedClientConfig: { + baseUrl: "https://embed.example.com", + modelId: "m", + apiStyle: "openai" as const, + }, + fetchImpl: openaiEmbedFetch(), + tenantId: "tenant-1", + principalId: null, + query: "hello", + overfetchLimit: 250, + }; + } + + it("excludes a semantically-similar chunk whose document kind does not match `kinds`", async () => { + const rows = await fetchDenseCandidates({ + ...baseArgs(fakeRawSql()), + kinds: ["task"], + }); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).not.toContain("chunk-note"); + }); + + it("excludes a semantically-similar chunk whose document is not linked to any requested entityId", async () => { + const rows = await fetchDenseCandidates({ + ...baseArgs(fakeRawSql()), + entityIds: ["e-match"], + }); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).not.toContain("chunk-note"); + }); + + it("applies no kind/entity predicate — and returns every semantically-similar chunk — when neither filter is provided", async () => { + const rows = await fetchDenseCandidates(baseArgs(fakeRawSql())); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).toContain("chunk-note"); + }); + + it("treats an empty kinds/entityIds array as no filter, same as lexical", async () => { + const rows = await fetchDenseCandidates({ + ...baseArgs(fakeRawSql()), + kinds: [], + entityIds: [], + }); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).toContain("chunk-note"); + }); +}); diff --git a/src/services/search.ts b/src/services/search.ts index a19d518..0360627 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -470,6 +470,14 @@ interface FetchDenseCandidatesArgs { principalId: string | null; query: string; overfetchLimit: number; + // Applied to this (dense) candidate query, same semantics as + // fetchLexicalCandidates: an empty array is treated identically to + // `undefined` — no filter — never "match nothing". + kinds?: string[] | undefined; + // Applied to this (dense) candidate query, same semantics as + // fetchLexicalCandidates: an empty array is treated identically to + // `undefined` — no filter — never "match nothing". + entityIds?: string[] | undefined; // Defaults to 'live' — see fetchLexicalCandidates' generation note. NOTE: // this filters the chunk/version join, not which per-model embedding // TABLE is queried — resolveActiveEmbedTable picks the tenant's single @@ -517,6 +525,8 @@ export async function fetchDenseCandidates( principalId, query, overfetchLimit, + kinds, + entityIds, generation = LIVE_GENERATION, } = args; @@ -561,6 +571,26 @@ export async function fetchDenseCandidates( params.push(generation); const generationParam = `$${params.length}`; + // Mirrors fetchLexicalCandidates' kind/entity predicates so the dense + // channel never surfaces a document the caller asked to exclude — a fused + // hit must match the filter regardless of which channel found it (empty + // array === no filter, same as the lexical side). + let kindClause = ""; + if (kinds && kinds.length > 0) { + params.push(kinds); + kindClause = `AND kd.kind = ANY($${params.length}::text[])`; + } + + let entityClause = ""; + if (entityIds && entityIds.length > 0) { + params.push(entityIds); + entityClause = `AND kd.id IN ( + SELECT ke.from_ref FROM knowledge_edge ke + WHERE ke.tenant_id = $1 AND ke.from_type = 'document' AND ke.to_type = 'entity' + AND ke.to_ref = ANY($${params.length}::text[]) + )`; + } + const sqlText = ` SELECT c.id AS chunk_id, c.document_id AS document_id, c.version_id AS version_id, kv.version AS version, kv.status AS status, kd.title AS title, kd.kind AS kind, @@ -574,6 +604,8 @@ export async function fetchDenseCandidates( WHERE e.tenant_id = $1 AND c.tenant_id = $1 AND kv.status = 'active' AND kv.generation = ${generationParam} AND ${visibilitySql} + ${kindClause} + ${entityClause} ORDER BY ${cosineDistanceExpr("e.embedding", vectorParam, activeTable.dims)} ASC LIMIT ${limitParam} `; @@ -754,18 +786,15 @@ export interface HybridSearchArgs { tenantId: string; principalId: string | null; k?: number | undefined; - // Constrains the LEXICAL channel only (see fetchLexicalCandidates) — the - // dense/semantic channel (fetchDenseCandidates) takes no kind/entity - // predicate and is unaffected. Fusion then merges both channels, so a hit - // that reached the result purely through the dense channel is NOT - // guaranteed to match `kinds`; this is a scoping hint on the lexical leg - // of retrieval, not an exact post-fusion filter over the final hit set. - // An empty array is treated identically to `undefined` (no filter) — it - // does NOT count as "provided" for the empty-query-requires-a-structured- - // filter check below. + // Applied to BOTH retrieval channels (fetchLexicalCandidates and + // fetchDenseCandidates) before fusion, so a document that doesn't match + // is never a candidate on either leg — no post-fusion gap. An empty array + // is treated identically to `undefined` (no filter) — it does NOT count + // as "provided" for the empty-query-requires-a-structured-filter check + // below. kinds?: string[] | undefined; - // Same lexical-channel-only scoping and empty-array-equals-absent - // semantics as `kinds`, above. + // Same both-channel scoping and empty-array-equals-absent semantics as + // `kinds`, above. entityIds?: string[] | undefined; // Defaults to 'live' — the normal capture/search behavior. A non-live // generation (a transform_run id, the replay pipeline) searches that replay's corpus @@ -796,13 +825,9 @@ export interface HybridSearchResult { * guaranteed to exceed the model's token limit (see `RerankQueryTooLongError` * in rerank-client.ts). * - * `kinds`/`entityIds` narrow the LEXICAL channel's candidate query only - * (see `HybridSearchArgs`). The dense channel has no equivalent predicate, - * so a document that doesn't match the requested kind/entity can still - * appear in the fused result if the dense channel surfaced it on semantic - * similarity. Callers that need an exact post-fusion guarantee must filter - * `hits` themselves; this is a retrieval-time scoping hint, not a hard - * result-set constraint. + * `kinds`/`entityIds` narrow BOTH channels' candidate queries (see + * `HybridSearchArgs`) before fusion runs, so every hit in the fused result + * matches the requested kind/entity — there is no dense-only escape hatch. */ export async function hybridSearch( deps: HybridSearchDeps, @@ -874,6 +899,8 @@ export async function hybridSearch( principalId, query, overfetchLimit, + kinds, + entityIds, generation, }); if (dense === null) { From b4429e9c1aeaa028db4d5ebb5066003febdd21ef Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 2 Aug 2026 10:26:46 -0700 Subject: [PATCH 4/6] Address review: dedupe filter types, document RRF safety, fix test name (CL-5021) - Extract ChannelFilterFields, shared by LexicalCandidateParams, FetchDenseCandidatesArgs, and HybridSearchArgs instead of redeclaring kinds/entityIds three times. - Document on fuseRrf why filtering each channel independently before fusion is safe: RRF only reads each survivor's rank within its own channel, never the survivor count or pool fraction, so removing non-matching candidates upstream can't distort it. A score-blending fusion would not share this property. - Rename the routes.test.ts empty-array test to match the file's short-name-plus-comment convention instead of embedding the rationale in the test name itself. --- src/core/hybrid-search.ts | 11 ++++++++++ src/routes/routes.test.ts | 4 +++- src/services/search.ts | 45 ++++++++++++++++----------------------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/core/hybrid-search.ts b/src/core/hybrid-search.ts index 7966093..57ff01c 100644 --- a/src/core/hybrid-search.ts +++ b/src/core/hybrid-search.ts @@ -50,6 +50,17 @@ export function toRankedCandidates( // `embeddings-rerank.md` §3's explicit rule). Returns candidates sorted // descending by fused score; ties are left in fusion-encounter order // (callers needing a further tiebreak, e.g. recency, apply it themselves). +// +// This is exactly why filtering each channel's candidates independently +// BEFORE they reach this function (kinds/entityIds in services/search.ts) +// never distorts relevance: a chunk's score here depends only on its OWN +// rank within each channel's surviving list, never on how many candidates +// survived or what fraction of the original pool they represent. Removing +// non-matching candidates upstream renumbers ranks but preserves each +// survivor's relative order, which is all RRF ever reads. A score-blending +// fusion would NOT have this property — removing candidates would shift +// score distributions (min/max, density) that a blend depends on — so this +// safety does not generalize past rank-based fusion. export function fuseRrf( channels: readonly (readonly RankedCandidate[])[], rrfK: number = RRF_K_DEFAULT, diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 91ed914..5fdd350 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -267,7 +267,9 @@ describe("knowledge HTTP routes", () => { expect(res.status).toBe(400); }); - test("search passes an EMPTY kinds/entity_ids array through unchanged — the route does not collapse [] to absent; hybridSearch treats both the same way (see services/search.ts)", async () => { + // The route does not collapse [] to absent — hybridSearch treats an empty + // array and an absent field the same way (see services/search.ts). + test("search passes an empty kinds/entity_ids array through unchanged", async () => { const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( "/api/knowledge/search", diff --git a/src/services/search.ts b/src/services/search.ts index 0360627..b648622 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -354,19 +354,22 @@ export async function attachEntityIds( return map; } -interface LexicalCandidateParams { +// The kinds/entityIds shape shared by both channels' candidate-query params +// and by HybridSearchArgs (which fans a single caller-supplied pair of these +// out to both channels before fusion). An empty array is treated identically +// to `undefined` — no filter — never "match nothing". +interface ChannelFilterFields { + kinds?: string[] | undefined; + entityIds?: string[] | undefined; +} + +interface LexicalCandidateParams extends ChannelFilterFields { db: Db; tenantId: string; principalId: string | null; query: string; ftsLanguage: string; overfetchLimit: number; - // Applied ONLY to this (lexical) candidate query. An empty array is - // treated identically to `undefined` — no filter — never "match nothing". - kinds?: string[] | undefined; - // Applied ONLY to this (lexical) candidate query. An empty array is - // treated identically to `undefined` — no filter — never "match nothing". - entityIds?: string[] | undefined; // Defaults to 'live' — every version-scoped query filters by generation so // a replayed generation's chunks never leak into a live search and vice // versa (the replay pipeline). @@ -462,7 +465,7 @@ export async function fetchLexicalCandidates( return rows as CandidateRow[]; } -interface FetchDenseCandidatesArgs { +interface FetchDenseCandidatesArgs extends ChannelFilterFields { sql: RawSql; embedClientConfig: EmbedClientConfig; fetchImpl: typeof fetch; @@ -470,14 +473,6 @@ interface FetchDenseCandidatesArgs { principalId: string | null; query: string; overfetchLimit: number; - // Applied to this (dense) candidate query, same semantics as - // fetchLexicalCandidates: an empty array is treated identically to - // `undefined` — no filter — never "match nothing". - kinds?: string[] | undefined; - // Applied to this (dense) candidate query, same semantics as - // fetchLexicalCandidates: an empty array is treated identically to - // `undefined` — no filter — never "match nothing". - entityIds?: string[] | undefined; // Defaults to 'live' — see fetchLexicalCandidates' generation note. NOTE: // this filters the chunk/version join, not which per-model embedding // TABLE is queried — resolveActiveEmbedTable picks the tenant's single @@ -781,21 +776,17 @@ export interface HybridSearchDeps { now?: Date | undefined; } -export interface HybridSearchArgs { +export interface HybridSearchArgs extends ChannelFilterFields { query: string; tenantId: string; principalId: string | null; k?: number | undefined; - // Applied to BOTH retrieval channels (fetchLexicalCandidates and - // fetchDenseCandidates) before fusion, so a document that doesn't match - // is never a candidate on either leg — no post-fusion gap. An empty array - // is treated identically to `undefined` (no filter) — it does NOT count - // as "provided" for the empty-query-requires-a-structured-filter check - // below. - kinds?: string[] | undefined; - // Same both-channel scoping and empty-array-equals-absent semantics as - // `kinds`, above. - entityIds?: string[] | undefined; + // `kinds`/`entityIds` (ChannelFilterFields) are applied to BOTH retrieval + // channels (fetchLexicalCandidates and fetchDenseCandidates) before + // fusion, so a document that doesn't match is never a candidate on either + // leg — no post-fusion gap. An empty array does NOT count as "provided" + // for the empty-query-requires-a-structured-filter check below. + // // Defaults to 'live' — the normal capture/search behavior. A non-live // generation (a transform_run id, the replay pipeline) searches that replay's corpus // instead, applying its transform_config's retrieval tuning when From 4f7e8e267173d953e9b028169492b8da25763b84 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 2 Aug 2026 10:30:56 -0700 Subject: [PATCH 5/6] Fix exactOptionalPropertyTypes mismatch between knowledge and service layers (CL-5021) KnowledgeSearchParams used the bare ?: T form for k/kinds/entityIds while services/search.ts's ChannelFilterFields already used ?: T | undefined. Widen KnowledgeSearchParams to match, since callers spread optional values that may be explicitly undefined. Also annotate the hybridSearch test double's return type so mockImplementation call sites can return a narrower/empty result without conflicting with the type inferred from the first mock body. --- src/knowledge.test.ts | 5 +++-- src/knowledge.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/knowledge.test.ts b/src/knowledge.test.ts index f92a2ec..c53c8cb 100644 --- a/src/knowledge.test.ts +++ b/src/knowledge.test.ts @@ -30,6 +30,7 @@ import { import type { KnowledgeConfig } from "./mount-config.ts"; import * as realDb from "./db/client.ts"; import * as realSearch from "./services/search.ts"; +import type { HybridSearchResult } from "./services/search.ts"; const PRINCIPAL = "p1"; const TENANT = "t1"; @@ -170,10 +171,10 @@ describe("createKnowledgePlane — construction validation", () => { }); describe("createKnowledgePlane.search — ACL post-filter wiring", () => { - const hybridSearch = mock(() => + const hybridSearch = mock((): Promise => Promise.resolve({ hits: [hit("d-blocked"), hit("d-open")], - evidence: "strong" as const, + evidence: "strong", }), ); diff --git a/src/knowledge.ts b/src/knowledge.ts index 3d831c5..c6f69c3 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -58,19 +58,19 @@ export type KnowledgeIdentity = { export type KnowledgeSearchParams = KnowledgeIdentity & { query: string; - k?: number; + k?: number | undefined; /** * Narrows every retrieval channel to documents whose `kind` is one of * these — see `hybridSearch` in services/search.ts. Applied before fusion, * so a fused hit is always guaranteed to match. Unset or an empty array * both mean "no filter" (equivalent, not "match nothing"). */ - kinds?: string[]; + kinds?: string[] | undefined; /** * Same scoping as `kinds`, restricted to documents linked to one of these * entity ids. Unset or an empty array both mean "no filter". */ - entityIds?: string[]; + entityIds?: string[] | undefined; }; export type KnowledgeAskParams = KnowledgeIdentity & { From 5713d874de5cdf2558e052dcbcb82f9d3af46417 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 2 Aug 2026 10:42:00 -0700 Subject: [PATCH 6/6] Test that RRF fusion is rank-based, not pool-size-aware (CL-5021) Guards the invariant just documented on fuseRrf: score depends only on a candidate's own rank within its channel, never how many candidates that channel started with. Constructs inputs where a percentile/pool-size-aware blend would rank oppositely, so a future switch away from rank-based fusion fails this test instead of silently breaking filter safety. --- src/core/hybrid-search.test.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/core/hybrid-search.test.ts b/src/core/hybrid-search.test.ts index 3b80c0d..7637dac 100644 --- a/src/core/hybrid-search.test.ts +++ b/src/core/hybrid-search.test.ts @@ -61,6 +61,42 @@ describe("fuseRrf", () => { test("empty channels produce no candidates", () => { expect(fuseRrf([[], []])).toEqual([]); }); + + // This test protects the invariant documented on fuseRrf above: fusion + // reads a candidate's own rank within its channel and nothing else — not + // how many candidates that channel started with or how many survived. If + // fuseRrf is ever rewritten to blend on something pool-size- or + // fraction-aware (e.g. normalizing rank to a percentile of the channel), + // this test must fail, because that is precisely the property being + // protected. + test("fuses by raw rank only, never by the size of the pool a candidate survived from", () => { + // "Y" is the worst survivor of a channel heavily thinned by filtering + // (rank 2 of ~2 candidates). "X" is a solid-but-unremarkable finisher in + // a channel filtering barely touched (rank 10 of ~100 candidates). This + // is exactly the shape independent per-channel filtering produces. + // + // Hand-computed RRF (rrfK=60), which reads only the rank number: + // score(Y) = 1/(60+2) = 1/62 ≈ 0.0161290 + // score(X) = 1/(60+10) = 1/70 ≈ 0.0142857 + // Y's raw rank (2) beats X's raw rank (10), so rank-based fusion ranks + // Y above X. + // + // A score-blending fusion that normalized each rank to a percentile + // within its own channel ((total - rank + 1) / total) would compute the + // OPPOSITE order: + // percentile(Y) = (2 - 2 + 1) / 2 = 0.50 (bottom half of a tiny pool) + // percentile(X) = (100 - 10 + 1) / 100 = 0.91 (top decile of a large pool) + // ranking X above Y. A change to that kind of blending flips this + // assertion and fails the test. + const heavilyFilteredChannel = [{ chunkId: "Y", rank: 2 }]; + const mostlyIntactChannel = [{ chunkId: "X", rank: 10 }]; + + const fused = fuseRrf([heavilyFilteredChannel, mostlyIntactChannel]); + + expect(fused.map((f) => f.chunkId)).toEqual(["Y", "X"]); + expect(fused[0]?.score).toBeCloseTo(1 / 62, 10); + expect(fused[1]?.score).toBeCloseTo(1 / 70, 10); + }); }); describe("isBatchQueriesWithinBound", () => {