Files
Lucas WintherandClaude Opus 5 c47f0669ca feat(search): add two-phase pipeline with bounded concurrency and recency backfill
Adds searchPipeline, which ranks candidates shallowly at no API cost, then
enriches a bounded subset (DEFAULT_ENRICH_LIMIT=25, DEFAULT_CONCURRENCY=8)
and re-ranks, taking max(shallow, deep) so a strong title match is never
diluted by unrelated deep content. Auth and rate-limit errors abort the
whole search via isFatalSearchError instead of degrading silently; other
enrichment failures degrade one record and are counted. A recency backfill
spends leftover enrichment budget on the most recently updated candidates
so a deep-only match is reachable at all. Adds the src/search/ barrel
export.

Reconciles the previously uncommitted pipeline.ts (written against an
earlier, defective version of this task's brief) with the corrected brief:
mapWithConcurrency's phase-2 callback is now given an explicit
EnrichedCandidate return type so its `deep: TDeep | undefined` field is a
single type rather than an inferred union, which is what the `entry is
Pair` narrowing predicate requires under TS2677.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 07:30:48 +02:00

205 lines
6.2 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { mapWithConcurrency, searchPipeline } from "../../src/search/pipeline.ts";
import { OpenProjectAuthenticationError, OpenProjectError } from "../../src/client/api-client.ts";
import type { FieldSpec } from "../../src/search/rank.ts";
interface Candidate {
id: number;
title: string;
updatedAt: string;
}
interface Enriched {
id: number;
notes: string;
}
function makeCandidates(count: number): Candidate[] {
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
title: `Item ${index + 1}`,
updatedAt: new Date(2026, 0, index + 1).toISOString(),
}));
}
const SHALLOW: FieldSpec<Candidate>[] = [{ name: "title", weight: 3, extract: (c) => c.title }];
const DEEP: FieldSpec<Enriched>[] = [{ name: "notes", weight: 2, extract: (e) => e.notes }];
describe("mapWithConcurrency", () => {
test("never exceeds the concurrency limit", async () => {
let inFlight = 0;
let peak = 0;
await mapWithConcurrency(Array.from({ length: 50 }, (_, i) => i), 8, async (item) => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight--;
return item;
});
expect(peak).toBeLessThanOrEqual(8);
});
test("preserves input order in the results", async () => {
const result = await mapWithConcurrency([3, 1, 2], 2, async (item) => {
await new Promise((resolve) => setTimeout(resolve, item));
return item * 10;
});
expect(result).toEqual([30, 10, 20]);
});
test("handles an empty input array", async () => {
expect(await mapWithConcurrency([], 4, async (i) => i)).toEqual([]);
});
});
describe("searchPipeline", () => {
test("caps enrichment at the enrich limit", async () => {
let enrichCalls = 0;
await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(100),
shallowFields: SHALLOW,
enrich: async (candidate) => {
enrichCalls++;
return { id: candidate.id, notes: "" };
},
deepFields: DEEP,
recencyOf: (candidate) => candidate.updatedAt,
},
"item"
);
expect(enrichCalls).toBe(25);
});
test("backfills the enrichment set by recency when few candidates match shallowly", async () => {
const enriched: number[] = [];
await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(100),
shallowFields: SHALLOW,
enrich: async (candidate) => {
enriched.push(candidate.id);
return { id: candidate.id, notes: "" };
},
deepFields: DEEP,
recencyOf: (candidate) => candidate.updatedAt,
},
"zzzznomatch"
);
// Nothing matches shallowly, so the whole budget is spent on the most
// recently updated candidates: ids 100 down to 76.
expect(enriched).toHaveLength(25);
expect(enriched[0]).toBe(100);
expect(enriched).toContain(76);
expect(enriched).not.toContain(75);
});
test("finds a record whose only match is in deep content", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(10),
shallowFields: SHALLOW,
enrich: async (candidate) => ({
id: candidate.id,
notes: candidate.id === 3 ? "We agreed on the hiring freeze" : "routine notes",
}),
deepFields: DEEP,
recencyOf: (candidate) => candidate.updatedAt,
},
"hiring freeze"
);
expect(result.ranked[0]!.record.id).toBe(3);
expect(result.ranked[0]!.deep?.notes).toContain("hiring freeze");
});
test("takes the maximum of shallow and deep scores rather than the sum", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => [{ id: 1, title: "Budget", updatedAt: "2026-01-01" }],
shallowFields: SHALLOW,
enrich: async () => ({ id: 1, notes: "totally unrelated filler text" }),
deepFields: DEEP,
},
"budget"
);
expect(result.ranked[0]!.score).toBeLessThanOrEqual(1);
expect(result.ranked[0]!.score).toBeGreaterThan(0.9);
});
test("marks the result degraded when an enrichment fails non-fatally", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async (candidate) => {
if (candidate.id === 2) {
throw new OpenProjectError("boom", { statusCode: 500 });
}
return { id: candidate.id, notes: "" };
},
deepFields: DEEP,
},
"item"
);
expect(result.degraded).toBe(true);
expect(result.enrichmentFailures).toBe(1);
expect(result.ranked.length).toBeGreaterThan(0);
});
test("propagates authentication errors instead of swallowing them", async () => {
const run = searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async () => {
throw new OpenProjectAuthenticationError();
},
deepFields: DEEP,
},
"item"
);
await expect(run).rejects.toThrow(OpenProjectAuthenticationError);
});
test("propagates rate limit errors instead of swallowing them", async () => {
const run = searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async () => {
throw new OpenProjectError("slow down", { statusCode: 429 });
},
deepFields: DEEP,
},
"item"
);
await expect(run).rejects.toThrow("slow down");
});
test("reports no degradation when every enrichment succeeds", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async (candidate) => ({ id: candidate.id, notes: "" }),
deepFields: DEEP,
},
"item"
);
expect(result.degraded).toBe(false);
expect(result.enrichmentFailures).toBe(0);
});
});