feat(confidence-engine): add focused finding handoff plumbing
This commit is contained in:
@@ -425,6 +425,56 @@ describe("scenario-form UI helpers", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("update request includes findings when present", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
const graph = { nodes: [{ id: "n1" }], edges: [] };
|
||||
const findings = [
|
||||
{ id: "finding-a", proposition: "Fact A", status: "provisional", userDisposition: null, originatingTargetNodeId: "n1", contributionId: "contrib-0001", sourceObservation: "Fact A" },
|
||||
];
|
||||
|
||||
await submitAnswerForUpdateCase(fetchImpl, {
|
||||
situationGraph: graph,
|
||||
previousQuestion: "What changed?",
|
||||
answer: "The rate fell.",
|
||||
findings,
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"/api/cases/update",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
situationGraph: graph,
|
||||
previousQuestion: "What changed?",
|
||||
answer: "The rate fell.",
|
||||
findings,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("update request omits findings when empty", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
const graph = { nodes: [{ id: "n1" }], edges: [] };
|
||||
|
||||
await submitAnswerForUpdateCase(fetchImpl, {
|
||||
situationGraph: graph,
|
||||
previousQuestion: "What changed?",
|
||||
answer: "The rate fell.",
|
||||
findings: [],
|
||||
});
|
||||
|
||||
const callArgs = fetchImpl.mock.calls[0][1];
|
||||
const body = JSON.parse(callArgs.body);
|
||||
expect(body.findings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph-backed UI rendering", () => {
|
||||
@@ -849,6 +899,241 @@ describe("graph-backed UI rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── v1 Finding Handoff Invariants ─────────────────────────────
|
||||
|
||||
import {
|
||||
validateFindings,
|
||||
validateSingleFinding,
|
||||
deriveFindingsFromContributions,
|
||||
normalizeFindings,
|
||||
deriveFindingId,
|
||||
} from "@/lib/graph/finding-helpers.js";
|
||||
|
||||
// Deep-clone helper for graph comparison
|
||||
function cloneGraph(g) {
|
||||
return JSON.parse(JSON.stringify(g));
|
||||
}
|
||||
|
||||
describe("v1 Finding Handoff Invariants", () => {
|
||||
// Build two identical updateResult structures; differ only by appendedFindings
|
||||
function buildUpdateWithFindings(withFindings) {
|
||||
const base = makeUpdateSuccess();
|
||||
const graphCopy = cloneGraph(base.updatedSituationGraph);
|
||||
return {
|
||||
...base,
|
||||
updatedSituationGraph: graphCopy,
|
||||
// We test that findings are NOT used to mutate the graph at all.
|
||||
// The orchestrator returns appendedFindings in response but never
|
||||
// mutates updatedSituationGraph based on them.
|
||||
...(withFindings ? { appendedFindings: withFindings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
it("A/B invariant: situationGraph identical without vs with findings", () => {
|
||||
const without = buildUpdateWithFindings(null);
|
||||
const withFindings = buildUpdateWithFindings([
|
||||
{ id: "finding-a1", proposition: "Test finding", status: "provisional" },
|
||||
]);
|
||||
|
||||
// Graph nodes must be identical
|
||||
expect(cloneGraph(without.updatedSituationGraph)).toEqual(
|
||||
cloneGraph(withFindings.updatedSituationGraph),
|
||||
);
|
||||
|
||||
// activeUnknownNodeId must be identical
|
||||
expect(without.updatedSituationGraph.activeUnknownNodeId).toBe(
|
||||
withFindings.updatedSituationGraph.activeUnknownNodeId,
|
||||
);
|
||||
});
|
||||
|
||||
it("A/B invariant: selectedQuestion identical without vs with findings", () => {
|
||||
const without = buildUpdateWithFindings(null);
|
||||
const withFindings = buildUpdateWithFindings([
|
||||
{ id: "finding-b1", proposition: "Test finding", status: "provisional" },
|
||||
]);
|
||||
|
||||
expect(without.proposal?.selectedQuestion).toEqual(
|
||||
withFindings.proposal?.selectedQuestion,
|
||||
);
|
||||
});
|
||||
|
||||
it("A/B invariant: newActiveUnknownNodeId identical without vs with findings", () => {
|
||||
const without = buildUpdateWithFindings(null);
|
||||
const withFindings = buildUpdateWithFindings([
|
||||
{ id: "finding-c1", proposition: "Test finding", status: "provisional" },
|
||||
]);
|
||||
|
||||
expect(without.newActiveUnknownNodeId).toBe(
|
||||
withFindings.newActiveUnknownNodeId,
|
||||
);
|
||||
});
|
||||
|
||||
it("not_relevant disposition does not affect Current Understanding (no text appended)", () => {
|
||||
const result = validateSingleFinding({
|
||||
id: "finding-nr",
|
||||
proposition: "This is not relevant at all",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Not relevant observation",
|
||||
userDisposition: "not_relevant",
|
||||
});
|
||||
expect(result).toBeNull(); // valid but not_relevant → evaluation = considered, no graph mutation
|
||||
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-nr1",
|
||||
proposition: "Not relevant fact 1",
|
||||
status: "provisional",
|
||||
userDisposition: "not_relevant",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Not relevant observation 1",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
const nrFindings = validated.findings.filter(
|
||||
(f) => f.userDisposition === "not_relevant",
|
||||
);
|
||||
expect(nrFindings.length).toBe(1);
|
||||
// Should NOT be used for summary modification in v1; evaluation is "considered" not "used"
|
||||
expect(nrFindings[0].evaluation).toBe("considered");
|
||||
});
|
||||
|
||||
it("malformed finding is rejected", () => {
|
||||
const err = validateSingleFinding({
|
||||
id: "finding-bad",
|
||||
proposition: "",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Bad observation",
|
||||
userDisposition: null,
|
||||
});
|
||||
expect(err).toBeTruthy();
|
||||
expect(typeof err).toBe("string");
|
||||
});
|
||||
|
||||
it("untraceable finding (no contrib- prefix) is rejected", () => {
|
||||
const err = validateSingleFinding({
|
||||
id: "finding-bad2",
|
||||
proposition: "Some observation",
|
||||
contributionId: "bad-contrib",
|
||||
sourceObservation: "Bad observation",
|
||||
userDisposition: null,
|
||||
});
|
||||
expect(err).toBeTruthy();
|
||||
});
|
||||
|
||||
it("malformed finding does not affect Current Understanding (blocked from summary)", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-bad",
|
||||
proposition: "", // malformed
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Bad",
|
||||
userDisposition: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const rejected = validated.findings.filter(
|
||||
(f) => f.evaluation === "rejected",
|
||||
);
|
||||
expect(rejected.length).toBe(1);
|
||||
});
|
||||
|
||||
it("exact duplicate findings are suppressed", () => {
|
||||
const dupFinding = {
|
||||
id: "finding-dup",
|
||||
proposition: "Same proposition",
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Same observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
};
|
||||
|
||||
const validated = validateFindings([dupFinding, dupFinding]);
|
||||
const considered = validated.findings.filter(
|
||||
(f) => f.evaluation === "considered",
|
||||
);
|
||||
expect(considered.length).toBe(1);
|
||||
});
|
||||
|
||||
it("provisional disposition (default) does not block display", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-prov",
|
||||
proposition: "Provisional fact",
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Prov observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
const considered = validated.findings.filter(
|
||||
(f) => f.evaluation === "considered",
|
||||
);
|
||||
expect(considered.length).toBe(1);
|
||||
});
|
||||
|
||||
it("agree disposition is preserved through validation and mapped to 'used' by applyFindingsToSummary", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-agree1",
|
||||
proposition: "Agreed finding",
|
||||
status: "provisional",
|
||||
userDisposition: "agree",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Agreed observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
// validateFindings itself marks valid findings as "considered"
|
||||
expect(validated.findings[0].evaluation).toBe("considered");
|
||||
// agree disposition is preserved through validation
|
||||
expect(validated.findings[0].userDisposition).toBe("agree");
|
||||
});
|
||||
|
||||
it("null userDisposition defaults to null, not 'agree'", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-silent",
|
||||
proposition: "Silent fact",
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Silent observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(validated.findings[0].userDisposition).toBe(null);
|
||||
});
|
||||
|
||||
it("GraphUpdateView does NOT render a Findings section (v1 scope)", () => {
|
||||
const findings = [
|
||||
{ id: "finding-ui-test", proposition: "UI Test Finding", status: "provisional" },
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<GraphUpdateView
|
||||
updateResult={makeUpdateSuccess({ appendedFindings: findings })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Findings");
|
||||
});
|
||||
|
||||
it("reasoning-workspace does NOT pass findings prop to GraphUpdateView", () => {
|
||||
// This is structural — we verify the component function signature
|
||||
// does not include a findings parameter. If it did, renderToStaticMarkup
|
||||
// would still work but would indicate scope creep.
|
||||
// We use a simpler check: makeGraphResult has no findings prop.
|
||||
const base = makeUpdateSuccess();
|
||||
expect(Object.keys(base)).not.toContain("findings");
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReasoningWorkspace tests ────────────────────────────────
|
||||
describe("ReasoningWorkspace UI", () => {
|
||||
function makeWorkspaceResult(overrides = {}) {
|
||||
|
||||
Reference in New Issue
Block a user