diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index 8dc22757afb..219f323d72e 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -2051,6 +2051,75 @@ describe('permitted-document planner', () => { expect(reachCounts()).toHaveLength(2) }) + it('reports a caller who reaches nothing as a bounded set of nothing, counted every time', async () => { + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (statement.includes('EXPLAIN')) + return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1_000_000 } }] }] + if (statement.includes(') reached')) return [{ n: 0 }] + return [] + }) + const reachCounts = () => statements().filter((query) => query.sql.includes(') reached')) + const plan = { + connectors: { workspace: [], admin: [], members: [], liveProofRequired: [] }, + observers: { confirmed: [], observed: [] }, + memberSources: [], + connectorTypes: new Map(), + uploads: true, + } + const budget = () => new SearchBudget('vector', performance.now() + 10_000) + await expect( + resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan) + ).resolves.toEqual({ kind: 'bounded', documents: [] }) + /** Emptiness decides completeness, so it is never remembered: the next search counts again. */ + await expect( + resolveReach(['org-index'], scope('reaches-nothing'), budget(), plan) + ).resolves.toEqual({ kind: 'bounded', documents: [] }) + expect(reachCounts()).toHaveLength(2) + }) + + it('reports a saturated probe whose count then finds nothing as a bounded set of nothing', async () => { + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (isProbeStatement(statement)) return [{ id: null, connectorId: null, saturated: true }] + if (statement.includes('EXPLAIN')) + return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1_000_000 } }] }] + if (statement.includes(') reached')) return [{ n: 0 }] + return [] + }) + await expect( + resolvePermittedDocuments({ + knowledgeBaseIds: ['org-index'], + access: scope('saturated-then-nothing'), + budget: new SearchBudget('vector', performance.now() + 10_000), + }) + ).resolves.toEqual({ kind: 'bounded', documents: [] }) + }) + + it('does not read an unanalyzed index as a reach of nothing', async () => { + /** The planner knows no rows yet, so the bound is zero and the count looked at nothing. */ + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (statement.includes('EXPLAIN')) return [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 0 } }] }] + if (statement.includes(') reached')) return [{ n: 0 }] + return [] + }) + await expect( + resolveReach( + ['org-index'], + scope('unanalyzed'), + new SearchBudget('vector', performance.now() + 10_000), + { + connectors: { workspace: [], admin: [], members: [], liveProofRequired: [] }, + observers: { confirmed: [], observed: [] }, + memberSources: [], + connectorTypes: new Map(), + uploads: true, + } + ) + ).resolves.toEqual({ kind: 'unbounded', broad: true }) + }) + it('reports a leg whose own deadline passed during the count as short, not failed', async () => { const budget = new SearchBudget('vector', performance.now() - 1) await expect( diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index 4ae24ac79ad..f2ff5bf066c 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -1184,7 +1184,19 @@ export const BROAD_REACH_SHARE = 0.25 */ const SATURATED_REACH_TTL_MS = 5 * 60 * 1000 -/** A saturated reach, and whether it is broad enough to walk the whole graph for. */ +/** + * A counted reach: whether it is broad enough to walk the whole graph for, or empty, in which + * case the caller reads nothing in these bases and no leg has anything to rank. + */ +interface CountedReach { + broad: boolean + empty: boolean +} + +/** + * Only breadth is remembered. Emptiness decides completeness, not strategy, so it is counted on + * every search: the count of a reach of nothing finds nothing and costs almost nothing. + */ const saturatedReach = new LRUCache({ max: 10_000, ttl: SATURATED_REACH_TTL_MS, @@ -1249,24 +1261,28 @@ async function estimateFilteredDocuments( } /** - * Whether a reach is broad: the caller reaches at least {@link BROAD_REACH_SHARE} of the bases' - * documents. Counted once against that bound and remembered, so the first search after the - * window pays for it and the rest do not. A caller whose probe already saturated is known to - * reach past the probe's limit, so a bound inside that limit is met without counting. + * How far a caller reaches: broad when they reach at least {@link BROAD_REACH_SHARE} of the + * bases' documents, empty when they reach none. A reach of nothing is a bounded set of nothing: a + * caller who reads no document in these bases, such as a member with no source of their own yet, + * has nothing for any leg to rank, where an unbounded set would have each leg scan to its + * deadline for rows it cannot find. Breadth is counted once against the bound and remembered, so + * the first search after the window pays for it and the rest do not. A caller whose probe already + * saturated is known to reach past the probe's limit, so a bound inside that limit is met without + * counting. * * The count reads as many index entries as the caller reaches, so on a large index it can cost * more than the leg it serves; it gets the probe's share of the deadline, never the whole leg's. * A count that runs out of that share answers `null`: the leg keeps its time and its deadline * intact, and the caller decides this search alone without remembering anything. */ -async function reachIsBroad( +async function countReach( knowledgeBaseIds: string[], access: KnowledgeAccessScope, budget: SearchBudget | undefined, plan: SearchAccessPlan | undefined, saturated: boolean -): Promise { - if (access.kind !== 'user') return true +): Promise { + if (access.kind !== 'user') return { broad: true, empty: false } const countBudget = budget?.capped(VECTOR_PROBE_BUDGET_MS) try { const total = @@ -1274,7 +1290,7 @@ async function reachIsBroad( context: countBudget, })) ?? 0 const bound = Math.ceil(total * BROAD_REACH_SHARE) - if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return true + if (saturated && bound <= VECTOR_PROBE_DOCUMENT_LIMIT) return { broad: true, empty: false } const [row] = await runSearchQuery(countBudget, 'permitted_documents', (executor) => executor.execute<{ n: number }>(sql` SELECT count(*) AS n FROM ( @@ -1288,7 +1304,9 @@ async function reachIsBroad( LIMIT ${bound} ) reached`) ) - return Number(row?.n ?? 0) >= bound + const reached = Number(row?.n ?? 0) + /** A count that looked and found nothing: only a bound of zero looks at nothing. */ + return { broad: reached >= bound, empty: bound > 0 && reached === 0 } } catch (error) { if (!budget || !countBudget?.isTimeout(error)) throw error /** Only the count's share was spent; the leg's own deadline still governs. */ @@ -1345,11 +1363,12 @@ export async function resolveReach( const remembered = key ? saturatedReach.get(key) : undefined if (remembered) return { kind: 'unbounded', broad: remembered.broad } try { - const broad = await reachIsBroad(knowledgeBaseIds, access, budget, plan, false) + const reach = await countReach(knowledgeBaseIds, access, budget, plan, false) /** A count that ran out of time decides this search only; the next one counts again. */ - if (broad === null) return { kind: 'unbounded', broad: true } - if (key) saturatedReach.set(key, { broad }) - return { kind: 'unbounded', broad } + if (reach === null) return { kind: 'unbounded', broad: true } + if (reach.empty) return { kind: 'bounded', documents: [] } + if (key) saturatedReach.set(key, { broad: reach.broad }) + return { kind: 'unbounded', broad: reach.broad } } catch (error) { /** The leg's own deadline passed during the count: the leg is short, the search is not failed. */ if (!budget?.isTimeout(error)) throw error @@ -1408,7 +1427,7 @@ export async function resolvePermittedDocuments(params: { } if (probe.kind === 'saturated') { try { - const counted = await reachIsBroad( + const reach = await countReach( params.knowledgeBaseIds, params.access, params.budget, @@ -1416,8 +1435,9 @@ export async function resolvePermittedDocuments(params: { true ) /** A count that ran out of time decides this search only; the next one counts again. */ - if (counted !== null) { - broad = counted + if (reach?.empty) probe = { kind: 'documents', documents: [] } + else if (reach !== null) { + broad = reach.broad if (key) saturatedReach.set(key, { broad }) } } catch (error) {