feat(confidence-engine): synthesize corrected findings

This commit is contained in:
2026-08-31 07:48:29 +01:00
parent 8e941b0c7b
commit 5fb32e628c
3 changed files with 255 additions and 6 deletions
@@ -298,3 +298,207 @@ describe("Synthesis trigger — focused findings commit (STATE-B)", () => {
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
// ── Correction trigger tests (v0.50) ────────────────────
describe("Synthesis trigger — corrected Finding (CORRECTION-A)", () => {
let capturedFetchCalls;
let capturedCU;
beforeEach(() => {
capturedFetchCalls = [];
capturedCU = "previous understanding";
global.fetch = vi.fn(async (url, init) => {
if (url === "/api/cases/synthesis") {
capturedFetchCalls.push(JSON.parse(init.body));
return new Response(
JSON.stringify({ currentUnderstanding: "Reconstructed from corrected findings." }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify({}), { status: 200 });
});
});
function simulateCorrectionWithSynthesis(findings, findingId, newProposition, situationGraph) {
// Mirror updateFindingProposition body: explicit next state
const nextFindings = findings.map((f) =>
f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f,
);
if (!situationGraph) return { findings: nextFindings };
void synthesizeFromFindings(fetch, {
situationGraph,
findings: normalizeFindings(nextFindings),
}).then((res) => {
if (res.ok && res.data?.currentUnderstanding) {
capturedCU = res.data.currentUnderstanding;
}
});
return { findings: nextFindings };
}
/* ── Case 1: Not quite alone ───────────────────────── */
it("Case 1 — clicking Not quite produces 0 synthesis calls", () => {
// Clicking Notquite only calls startEditing locally in reasoning-workspace.
// No parent callback (updateFindingProposition) is invoked.
// Therefore no synthesis occurs.
expect(capturedFetchCalls).toHaveLength(0);
});
/* ── Case 2: save correction ─────────────────────── */
it("Case 2 — saving corrected proposition triggers synthesis", async () => {
const originalProposition = "Revenue dropped 22%";
const correctedProposition = "Revenue dropped approximately 18% in Q3";
const findingId = "finding-001";
const existingFindings = [
{
id: findingId,
proposition: originalProposition,
userDisposition: "not_quite",
sourceObservation: originalProposition,
contributionId: "contrib-0001",
originatingTargetNodeId: "n-a",
},
];
simulateCorrectionWithSynthesis(
existingFindings,
findingId,
correctedProposition,
{ centralStatement: "Q3 financial decline" },
);
// synthesis called exactly once
expect(capturedFetchCalls).toHaveLength(1);
// correct proposition sent
expect(capturedFetchCalls[0].findings[0].proposition).toBe(correctedProposition);
expect(capturedFetchCalls[0].findings[0].userDisposition).toBeNull();
// userDisposition reset to null on correction save
// same id preserved
expect(capturedFetchCalls[0].findings[0].id).toBe(findingId);
// same sourceObservation preserved
expect(capturedFetchCalls[0].findings[0].sourceObservation).toBe(originalProposition);
// old proposition not present for this finding
expect(capturedFetchCalls[0].findings[0].proposition).not.toBe(originalProposition);
// await microtask to complete CU update
await new Promise((r) => setTimeout(r, 10));
expect(capturedCU).toBe("Reconstructed from corrected findings.");
expect(capturedCU).not.toContain("previous");
});
/* ── Case 3: multiple Findings ───────────────────── */
it("Case 3 — correcting one Finding sends all canonical Findings", async () => {
const findingA = "finding-aaa";
const findingB = "finding-bbb";
const findingC = "finding-ccc";
const existingFindings = [
{ id: findingA, proposition: "Fact A originally", userDisposition: null, sourceObservation: "Fact A originally" },
{ id: findingB, proposition: "Fact B originally", userDisposition: "agree", sourceObservation: "Fact B originally" },
{ id: findingC, proposition: "Fact C original text", userDisposition: null, sourceObservation: "Fact C original text" },
];
simulateCorrectionWithSynthesis(
existingFindings,
findingB,
"Fact B corrected version",
{ centralStatement: "Multi-finding scenario" },
);
expect(capturedFetchCalls).toHaveLength(1);
expect(capturedFetchCalls[0].findings).toHaveLength(3);
// Only findingB changed
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingA).proposition).toBe("Fact A originally");
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingB).proposition).toBe("Fact B corrected version");
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingC).proposition).toBe("Fact C original text");
// Only findingB has userDisposition reset to null (normalised away)
const bFinding = capturedFetchCalls[0].findings.find((f) => f.id === findingB);
expect(bFinding.userDisposition).toBeNull();
});
/* ── Case 4: synthesis success replaces CU ───────── */
it("Case 4 — successful synthesis replaces Current Understanding", async () => {
capturedCU = "old evidence block text";
const existingFindings = [
{ id: "f-1", proposition: "Original text", userDisposition: "not_quite", sourceObservation: "Original text" },
];
simulateCorrectionWithSynthesis(
existingFindings,
"f-1",
"Corrected text",
{ centralStatement: "test" },
);
await new Promise((r) => setTimeout(r, 10));
expect(capturedCU).toBe("Reconstructed from corrected findings.");
expect(capturedCU).not.toContain("old");
expect(capturedCU).not.toContain("evidence block text");
});
/* ── Case 5: synthesis failure semantics ─────────── */
it("Case 5 — on synthesis failure: correction preserved, CU unchanged, no retry", async () => {
capturedCU = "previous understanding";
// Override fetch to fail (but still capture the call)
global.fetch = vi.fn(async (url, init) => {
if (url === "/api/cases/synthesis") {
capturedFetchCalls.push(JSON.parse(init.body));
return new Response(
JSON.stringify({ success: false, error: "provider timeout" }),
{ status: 503 },
);
}
return new Response(JSON.stringify({}), { status: 200 });
});
const originalProposition = "Fact X";
const existingFindings = [
{ id: "f-1", proposition: originalProposition, userDisposition: null, sourceObservation: originalProposition },
];
const result = simulateCorrectionWithSynthesis(
existingFindings,
"f-1",
"Corrected Fact X",
{ centralStatement: "test" },
);
// Correction was applied to nextFindings locally (explicit state)
expect(result.findings[0].proposition).toBe("Corrected Fact X");
expect(result.findings[0].userDisposition).toBeNull();
expect(result.findings[0].id).toBe("f-1");
expect(result.findings[0].sourceObservation).toBe(originalProposition);
await new Promise((r) => setTimeout(r, 10));
// CU unchanged — no legacy append fallback
expect(capturedCU).toBe("previous understanding");
// synthesis called exactly once (no automatic retry)
expect(global.fetch).toHaveBeenCalledTimes(1);
// Request body contains corrected proposition, not stale original
const reqFindings = capturedFetchCalls[0].findings;
expect(reqFindings.length).toBeGreaterThan(0);
});
});