Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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`).
Expand Down
7 changes: 6 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,18 @@ 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
```

Requests are authenticated upstream by Interchange (however the host chose);
the SDK routes assume a resolved principal on the context.

`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

Optional on capture; default is scope-wide (the company brain), or
Expand Down
36 changes: 36 additions & 0 deletions src/core/hybrid-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
11 changes: 11 additions & 0 deletions src/core/hybrid-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 36 additions & 2 deletions src/knowledge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -170,10 +171,10 @@ describe("createKnowledgePlane — construction validation", () => {
});

describe("createKnowledgePlane.search — ACL post-filter wiring", () => {
const hybridSearch = mock(() =>
const hybridSearch = mock((): Promise<HybridSearchResult> =>
Promise.resolve({
hits: [hit("d-blocked"), hit("d-open")],
evidence: "strong" as const,
evidence: "strong",
}),
);

Expand Down Expand Up @@ -255,6 +256,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.
Expand Down
16 changes: 15 additions & 1 deletion src/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +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[] | 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[] | undefined;
};

export type KnowledgeAskParams = KnowledgeIdentity & {
Expand Down Expand Up @@ -279,6 +291,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
Expand Down
63 changes: 59 additions & 4 deletions src/routes/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ function stubPlane(opts?: {
}) {
const captured: { title: string; tenantId: string; principalId: string }[] =
[];
const searched: Array<
Pick<Parameters<KnowledgePlane["search"]>[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({
Expand All @@ -61,7 +67,7 @@ function stubPlane(opts?: {
},
close: async () => {},
};
return { plane, captured };
return { plane, captured, searched };
}

function buildApp(
Expand All @@ -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: {},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -224,6 +230,55 @@ 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);
});

// 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",
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");
Expand Down
17 changes: 16 additions & 1 deletion src/routes/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,18 @@ import { SearchResponseSchema } from "../core/schemas/search.ts";
import type { RouteDeps } from "./deps.ts";
import { caller, grantGuard, requirePrincipal } from "./deps.ts";

// `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
// `query` be paired with a non-empty structured filter.
const SearchRequest = type({
query: "string >= 1",
"k?": "1 <= number.integer <= 50",
"kinds?": "string[]",
"entity_ids?": "string[]",
});

export function mountSearchRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
Expand All @@ -20,6 +29,10 @@ export function mountSearchRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
describeRoute({
tags: ["knowledge"],
summary: "Hybrid semantic + keyword search",
description:
"`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",
Expand All @@ -36,14 +49,16 @@ export function mountSearchRoute(app: Hono<TenantEnv>, 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({
query,
tenantId: scopeId,
principalId: subjectId,
...(k !== undefined ? { k } : {}),
...(kinds !== undefined ? { kinds } : {}),
...(entity_ids !== undefined ? { entityIds: entity_ids } : {}),
});
return c.json(result);
} catch (err) {
Expand Down
Loading
Loading