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]>
205 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|