feat(extraction): stage engines resolve the slot chain — engineFor on the pipeline

Chunk B, item 3 of the search port + slots wave (2026-09-20): the flagged
gap closed — ExtractionModels deps were engine VALUES, keeping
extractor/auditor/labeler config-pin-bound while the job runner went
slot-native. The pipeline now takes the runner's exact treatment:

- ExtractionPipelineDeps.engineFor: (role) => PiEngine — resolved FRESH
  per stage call, so a settings PUT between stages lands. Direct test
  values (extractor/auditor/labeler) remain the priority seam, checked
  FIRST; absent values fall to the chain; absent chain → config pins
  (unchanged).
- All six stage call sites (chat + notes extract/audit, the cluster
  namer x2) resolve through stageEngine(role, direct).
- chat-handler's extraction composition injects the full chain —
  piEngineForSpec(resolveRoleSpec(role, readSettings(), MODEL_PINS)) —
  same wiring as the job runner.

Chunk B, item 4 (session-level slot-anchored model picks): VERIFIED
already complete from Platform's model-slots wave — the conversation
engine chain (chat-handler) anchors on effective.models.strong, the
picker's default surface (catalogDefaultsFor) falls back to strong,
both pinned in tests. No phase split (user decision, 2026-09-20).
Capability-filtered pickers live in the settings UI (main 343be10).

Tests: engineFor fills the stages end-to-end (both propositions merged
through the chain, consulted in pipeline order) + the direct-value
priority pin (engineFor that throws when consulted).

Gates: typecheck, 795/795 tests, lint, build — all green.
This commit is contained in:
amosblomqvist
2026-09-22 13:59:25 +03:00
parent ed264c936d
commit 372d7f9975
3 changed files with 85 additions and 7 deletions
+58
View File
@@ -401,6 +401,64 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
);
});
it("engineFor fills the stages (model slots wave): the chain replaces config pins", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const verdict: AuditResult = {
propositions: FINDINGS.propositions,
edges: [], // the edge is dropped with it
attribution: FINDINGS.attribution,
dropped: [{ ref: "p1→p2", reason: "endpoint dropped" }],
};
const consulted: string[] = [];
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
embedder: fakeEmbedder,
// NO direct values — the stages resolve ENTIRELY through engineFor
// (the role override → slot → factory-pin chain, read fresh per
// stage call in production; here, the scripted chain).
engineFor: (role) => {
consulted.push(role);
if (role === "extractor") return submitModel("submit_findings", FINDINGS);
if (role === "auditor") return submitModel("submit_review", verdict);
return submitModel("submit_labels", { labels: [] });
},
});
const stats = await pipeline.runExtraction(chatId);
assert.equal(stats.nodes, 2, "the chain-fed pipeline ran end to end — both propositions merged");
// The stages were resolved through the chain, in pipeline order (the
// labeler is fire-and-forget garnish — extractor + auditor are the
// pinned order).
assert.equal(consulted[0], "extractor");
assert.equal(consulted[1], "auditor");
});
it("direct test values still WIN over engineFor (the priority pin)", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const verdict: AuditResult = {
propositions: FINDINGS.propositions,
edges: [],
attribution: FINDINGS.attribution,
dropped: [{ ref: "p1→p2", reason: "endpoint dropped" }],
};
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
embedder: fakeEmbedder,
extractor: submitModel("submit_findings", FINDINGS),
auditor: submitModel("submit_review", verdict),
labeler: submitModel("submit_labels", { labels: [] }),
// If the direct values did NOT win, this throw would surface.
engineFor: (role) => {
throw new Error(`engineFor should not be consulted for ${role} when a direct value is set`);
},
});
const stats = await pipeline.runExtraction(chatId);
assert.equal(stats.nodes, 2, "the direct-value seams ran, untouched by engineFor");
});
it("checkpoints nothing when a stage fails", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();