From cb77f955edb7ff9c71bd9e41cf70958b2c6fdb96 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 2 Aug 2026 08:24:56 +0100 Subject: [PATCH] feat: apply validated graph update proposals --- lib/graph/apply-proposal.js | 292 +++++++++++++++++++++ lib/graph/orchestrator.js | 53 ++++ tests/graph/apply-proposal.test.js | 404 +++++++++++++++++++++++++++++ tests/graph/orchestrator.test.js | 120 +++++++++ 4 files changed, 869 insertions(+) create mode 100644 lib/graph/apply-proposal.js create mode 100644 tests/graph/apply-proposal.test.js diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js new file mode 100644 index 0000000..403b311 --- /dev/null +++ b/lib/graph/apply-proposal.js @@ -0,0 +1,292 @@ +import { describeGraph } from "./builder.js"; +import { graphUpdateSchema, situationGraphSchema } from "./schema.js"; +import { + applyGraphUpdate, + detectDuplicateNodeIds, + findAffectedNodes, + selectActiveUnknownCandidate, + validateGraphReferences, + validateGraphUpdate, +} from "./utils.js"; + +function cloneJsonSafe(value) { + return JSON.parse(JSON.stringify(value)); +} + +function zodIssuesToErrors(error) { + return ( + error?.issues?.map((issue) => { + const path = issue.path?.length ? `${issue.path.join(".")}: ` : ""; + return `${path}${issue.message}`; + }) ?? ["Validation failed"] + ); +} + +function collectDuplicateEdgeIds(edges) { + const counts = new Map(); + + for (const edge of edges) { + counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1); + } + + return [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([edgeId, count]) => ({ edgeId, count })); +} + +function buildAffectedNodeIds(graph, proposal) { + const affected = new Set(proposal.affectedNodeIds ?? []); + + for (const update of proposal.updatedNodes ?? []) { + affected.add(update.nodeId); + for (const nodeId of findAffectedNodes(graph, update.nodeId)) { + affected.add(nodeId); + } + } + + for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) { + affected.add(nodeId); + for (const affectedNodeId of findAffectedNodes(graph, nodeId)) { + affected.add(affectedNodeId); + } + } + + return [...affected]; +} + +function buildChangesApplied(proposal, affectedNodeIds) { + return { + addedNodeCount: proposal.addedNodes.length, + updatedNodeCount: proposal.updatedNodes.length, + addedEdgeCount: proposal.addedEdges.length, + removedEdgeCount: proposal.removedEdgeIds.length, + resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length, + affectedNodeCount: affectedNodeIds.length, + }; +} + +export function applyValidatedProposal({ situationGraph, proposal }) { + const graphValidation = situationGraphSchema.safeParse(situationGraph); + const proposalValidation = graphUpdateSchema.safeParse(proposal); + + const existingGraphReferenceValidation = graphValidation.success + ? validateGraphReferences(situationGraph) + : null; + + const existingDuplicateNodeIds = graphValidation.success + ? detectDuplicateNodeIds(situationGraph.nodes) + : []; + const existingDuplicateEdgeIds = graphValidation.success + ? collectDuplicateEdgeIds(situationGraph.edges) + : []; + + if ( + !graphValidation.success || + !existingGraphReferenceValidation?.valid || + existingDuplicateNodeIds.length > 0 || + existingDuplicateEdgeIds.length > 0 + ) { + return { + success: false, + stage: "graph_validation", + errors: [ + ...(!graphValidation.success + ? zodIssuesToErrors(graphValidation.error) + : []), + ...(!existingGraphReferenceValidation?.valid + ? existingGraphReferenceValidation.errors + : []), + ...existingDuplicateNodeIds.map( + ({ nodeId, count }) => + `Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`, + ), + ...existingDuplicateEdgeIds.map( + ({ edgeId, count }) => + `Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`, + ), + ], + }; + } + + if (!proposalValidation.success) { + return { + success: false, + stage: "proposal_compatibility", + errors: zodIssuesToErrors(proposalValidation.error), + }; + } + + const validatedProposal = proposalValidation.data; + const proposalCompatibilityErrors = []; + const proposalGraphValidation = validateGraphUpdate( + situationGraph, + validatedProposal, + ); + + if (!proposalGraphValidation.valid) { + proposalCompatibilityErrors.push(...proposalGraphValidation.errors); + } + + const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id)); + const reachableNodeIds = new Set([ + ...situationGraph.nodes.map((node) => node.id), + ...validatedProposal.addedNodes.map((node) => node.id), + ]); + const addedEdgeDuplicateIds = collectDuplicateEdgeIds( + validatedProposal.addedEdges, + ); + proposalCompatibilityErrors.push( + ...addedEdgeDuplicateIds.map( + ({ edgeId, count }) => + `Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`, + ), + ); + + for (const edge of validatedProposal.addedEdges) { + if (existingEdgeIds.has(edge.id)) { + proposalCompatibilityErrors.push( + `Cannot add edge with duplicate ID: "${edge.id}"`, + ); + } + if (!reachableNodeIds.has(edge.fromNodeId)) { + proposalCompatibilityErrors.push( + `Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`, + ); + } + if (!reachableNodeIds.has(edge.toNodeId)) { + proposalCompatibilityErrors.push( + `Added edge references non-existent toNodeId: "${edge.toNodeId}"`, + ); + } + } + + const removedEdgeIds = new Set(validatedProposal.removedEdgeIds); + for (const edgeId of removedEdgeIds) { + if (!existingEdgeIds.has(edgeId)) { + proposalCompatibilityErrors.push( + `Cannot remove non-existent edge: "${edgeId}"`, + ); + } + } + + const combinedNodeDuplicates = detectDuplicateNodeIds([ + ...situationGraph.nodes, + ...validatedProposal.addedNodes, + ]); + proposalCompatibilityErrors.push( + ...combinedNodeDuplicates.map( + ({ nodeId, count }) => + `Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`, + ), + ); + + if (proposalCompatibilityErrors.length > 0) { + return { + success: false, + stage: "proposal_compatibility", + errors: proposalCompatibilityErrors, + }; + } + + const graphSnapshot = cloneJsonSafe(situationGraph); + const proposalSnapshot = cloneJsonSafe(validatedProposal); + const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null; + const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot); + + const applied = applyGraphUpdate(graphSnapshot, proposalSnapshot); + if (!applied.success) { + return { + success: false, + stage: "application", + errors: applied.errors, + }; + } + + const updatedSituationGraph = { + ...graphSnapshot, + nodes: applied.nodes, + edges: applied.edges, + resolvedNodeIds: applied.resolvedNodeIds, + }; + + const activeUnknownWasResolved = + previousActiveUnknownNodeId != null && + updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId); + + let newActiveUnknownNodeId = previousActiveUnknownNodeId; + if (activeUnknownWasResolved) { + newActiveUnknownNodeId = null; + } + + const remainingUnknownExists = + newActiveUnknownNodeId != null && + updatedSituationGraph.nodes.some( + (node) => + node.id === newActiveUnknownNodeId && + node.kind === "unknown" && + !updatedSituationGraph.resolvedNodeIds.includes(node.id), + ); + + if (!remainingUnknownExists) { + newActiveUnknownNodeId = + selectActiveUnknownCandidate( + updatedSituationGraph, + updatedSituationGraph.resolvedNodeIds, + )?.nodeId ?? null; + } + + updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId; + updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); + + const resultGraphValidation = situationGraphSchema.safeParse( + updatedSituationGraph, + ); + const resultReferenceValidation = resultGraphValidation.success + ? validateGraphReferences(updatedSituationGraph) + : null; + const resultDuplicateNodeIds = resultGraphValidation.success + ? detectDuplicateNodeIds(updatedSituationGraph.nodes) + : []; + const resultDuplicateEdgeIds = resultGraphValidation.success + ? collectDuplicateEdgeIds(updatedSituationGraph.edges) + : []; + + if ( + !resultGraphValidation.success || + !resultReferenceValidation?.valid || + resultDuplicateNodeIds.length > 0 || + resultDuplicateEdgeIds.length > 0 + ) { + return { + success: false, + stage: "result_validation", + errors: [ + ...(!resultGraphValidation.success + ? zodIssuesToErrors(resultGraphValidation.error) + : []), + ...(!resultReferenceValidation?.valid + ? resultReferenceValidation.errors + : []), + ...resultDuplicateNodeIds.map( + ({ nodeId, count }) => + `Updated graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`, + ), + ...resultDuplicateEdgeIds.map( + ({ edgeId, count }) => + `Updated graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`, + ), + ], + }; + } + + return { + success: true, + updatedSituationGraph, + graphUpdate: validatedProposal, + affectedNodeIds, + resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, + previousActiveUnknownNodeId, + newActiveUnknownNodeId, + changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds), + }; +} diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index 62038a8..967be9b 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -13,6 +13,7 @@ import { updateCaseRequestSchema, } from "./schema.js"; import { buildInitialGraph, describeGraph } from "./builder.js"; +import { applyValidatedProposal } from "./apply-proposal.js"; import { buildGraphUpdatePrompt } from "./prompt-builder.js"; import { parseGraphUpdateProposal } from "./update-proposal.js"; import { @@ -180,6 +181,9 @@ async function updateCaseWithDependencies(body, dependencies = {}) { dependencies.buildGraphUpdatePrompt ?? buildGraphUpdatePrompt; const parseProposal = dependencies.parseGraphUpdateProposal ?? parseGraphUpdateProposal; + const applyProposalUpdate = + dependencies.applyValidatedProposal ?? applyValidatedProposal; + const shouldApplyProposal = dependencies.applyProposal === true; let modelName = null; let rawResponse; @@ -238,6 +242,55 @@ async function updateCaseWithDependencies(body, dependencies = {}) { }; } + if (shouldApplyProposal) { + const applicationResult = applyProposalUpdate({ + situationGraph, + proposal: parsedProposal.proposal, + }); + + if (!applicationResult.success) { + return { + success: false, + stage: applicationResult.stage, + errors: applicationResult.errors, + diagnostics: { + promptVersion: promptVersion ?? null, + modelName, + responseDurationMs, + normalisationsApplied: parsedProposal.normalisationsApplied, + graphNodeCount: situationGraph.nodes.length, + graphEdgeCount: situationGraph.edges.length, + }, + statusCode: + applicationResult.stage === "application" || + applicationResult.stage === "result_validation" + ? 500 + : 400, + }; + } + + return { + success: true, + stage: "update_applied", + updatedSituationGraph: applicationResult.updatedSituationGraph, + proposal: applicationResult.graphUpdate, + affectedNodeIds: applicationResult.affectedNodeIds, + resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds, + previousActiveUnknownNodeId: + applicationResult.previousActiveUnknownNodeId, + newActiveUnknownNodeId: applicationResult.newActiveUnknownNodeId, + changesApplied: applicationResult.changesApplied, + diagnostics: { + promptVersion: promptVersion ?? null, + modelName, + responseDurationMs, + normalisationsApplied: parsedProposal.normalisationsApplied, + graphNodeCount: applicationResult.updatedSituationGraph.nodes.length, + graphEdgeCount: applicationResult.updatedSituationGraph.edges.length, + }, + }; + } + return { success: true, stage: "proposal_ready", diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js new file mode 100644 index 0000000..781f3eb --- /dev/null +++ b/tests/graph/apply-proposal.test.js @@ -0,0 +1,404 @@ +import { describe, expect, it } from "vitest"; +import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; +import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; +import { validateGraphReferences } from "@/lib/graph/utils.js"; + +function makeApplicationFixture() { + const complaintRateUnknown = makeNode({ + id: "n-complaint-rate-unknown", + label: "Complaint rate", + description: "Need the complaint rate per 100 units", + kind: "unknown", + status: "unknown", + confidence: "high", + affects: ["n-quality-deterioration"], + }); + const staffingUnknown = makeNode({ + id: "n-staffing-unknown", + label: "Staffing change", + description: "Need to know if staffing changed", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const qualityDeterioration = makeNode({ + id: "n-quality-deterioration", + label: "Quality deterioration conclusion", + description: "Conclusion that quality deteriorated", + kind: "conclusion", + status: "supported", + confidence: "medium", + dependsOn: ["n-complaint-rate-unknown"], + }); + const complaintCount = makeNode({ + id: "n-complaint-count", + label: "Complaint count observation", + description: "Complaint count increased", + kind: "observation", + status: "supported", + confidence: "high", + value: 135, + unit: "count", + }); + const productionCount = makeNode({ + id: "n-production-count", + label: "Production count observation", + description: "Production increased", + kind: "observation", + status: "supported", + confidence: "high", + value: 7100, + unit: "units", + }); + + const graph = makeGraph({ + centralStatement: "Complaints rose while production also rose.", + nodes: [ + complaintRateUnknown, + staffingUnknown, + qualityDeterioration, + complaintCount, + productionCount, + ], + edges: [ + makeEdge({ + id: "e-quality-depends-rate", + fromNodeId: complaintRateUnknown.id, + toNodeId: qualityDeterioration.id, + relationship: "supports", + confidence: "medium", + description: "The rate informs the quality conclusion", + }), + ], + activeUnknownNodeId: complaintRateUnknown.id, + resolvedNodeIds: [], + currentSummary: "Initial summary", + }); + + const proposal = { + addedNodes: [], + updatedNodes: [ + { + nodeId: complaintRateUnknown.id, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: "2.0 complaints per 100 units", + newValue: "1.9 complaints per 100 units", + reason: "The answer provides the updated normalized complaint rate.", + }, + { + nodeId: qualityDeterioration.id, + previousStatus: "supported", + newStatus: "weakened", + previousValue: null, + newValue: null, + reason: "The improved rate weakens the deterioration conclusion.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [complaintRateUnknown.id], + affectedNodeIds: [qualityDeterioration.id], + }; + + return { + graph, + proposal, + ids: { + complaintRateUnknown: complaintRateUnknown.id, + staffingUnknown: staffingUnknown.id, + qualityDeterioration: qualityDeterioration.id, + complaintCount: complaintCount.id, + productionCount: productionCount.id, + }, + }; +} + +describe("applyValidatedProposal", () => { + it("applies a valid proposal successfully", () => { + const { graph, proposal, ids } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal, + }); + + expect(result).toMatchObject({ + success: true, + graphUpdate: proposal, + resolvedUnknownNodeIds: [ids.complaintRateUnknown], + previousActiveUnknownNodeId: ids.complaintRateUnknown, + newActiveUnknownNodeId: ids.staffingUnknown, + }); + expect( + result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.complaintRateUnknown, + )?.status, + ).toBe("resolved"); + expect( + result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.qualityDeterioration, + )?.status, + ).toBe("weakened"); + }); + + it("rejects an invalid graph before application", () => { + const { graph, proposal } = makeApplicationFixture(); + graph.nodes[0].dependsOn.push("missing-node"); + + const original = JSON.parse(JSON.stringify(graph)); + const result = applyValidatedProposal({ + situationGraph: graph, + proposal, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("graph_validation"); + expect(graph).toEqual(original); + }); + + it("rejects updates referencing nonexistent nodes", () => { + const { graph, proposal } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + ...proposal, + updatedNodes: [ + ...proposal.updatedNodes, + { + nodeId: "ghost-node", + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: null, + reason: "Invalid reference", + }, + ], + }, + }); + + expect(result).toMatchObject({ + success: false, + stage: "proposal_compatibility", + }); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'Cannot update non-existent node: "ghost-node"', + ), + ]), + ); + }); + + it("rejects added edges with invalid references", () => { + const { graph, proposal } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + ...proposal, + addedEdges: [ + makeEdge({ + id: "e-invalid", + fromNodeId: "missing-node", + toNodeId: "n-quality-deterioration", + relationship: "supports", + confidence: "medium", + description: "Invalid edge", + }), + ], + }, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_compatibility"); + }); + + it("rejects duplicate IDs", () => { + const { graph, proposal, ids } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + ...proposal, + addedNodes: [ + makeNode({ + id: ids.qualityDeterioration, + label: "Duplicate", + description: "Duplicate node id", + }), + ], + }, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_compatibility"); + expect(result.errors.join(" ")).toContain("duplicate node ID"); + }); + + it("preserves unrelated nodes byte-for-byte", () => { + const { graph, proposal, ids } = makeApplicationFixture(); + const originalComplaintCount = JSON.stringify( + graph.nodes.find((node) => node.id === ids.complaintCount), + ); + const originalProductionCount = JSON.stringify( + graph.nodes.find((node) => node.id === ids.productionCount), + ); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal, + }); + + expect(result.success).toBe(true); + expect( + JSON.stringify( + result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.complaintCount, + ), + ), + ).toBe(originalComplaintCount); + expect( + JSON.stringify( + result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.productionCount, + ), + ), + ).toBe(originalProductionCount); + }); + + it("adds resolved unknowns to resolvedNodeIds", () => { + const { graph, proposal, ids } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal, + }); + + expect(result.success).toBe(true); + expect(result.updatedSituationGraph.resolvedNodeIds).toContain( + ids.complaintRateUnknown, + ); + }); + + it("keeps the active unknown when it remains unresolved", () => { + const { graph, ids } = makeApplicationFixture(); + const proposal = { + addedNodes: [], + updatedNodes: [ + { + nodeId: ids.qualityDeterioration, + previousStatus: "supported", + newStatus: "weakened", + previousValue: null, + newValue: null, + reason: "Only the conclusion changes", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: [ids.qualityDeterioration], + }; + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal, + }); + + expect(result.success).toBe(true); + expect(result.previousActiveUnknownNodeId).toBe(ids.complaintRateUnknown); + expect(result.newActiveUnknownNodeId).toBe(ids.complaintRateUnknown); + }); + + it("reports affected node ids", () => { + const { graph, proposal, ids } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal, + }); + + expect(result.success).toBe(true); + expect(result.affectedNodeIds).toEqual( + expect.arrayContaining([ + ids.complaintRateUnknown, + ids.qualityDeterioration, + ]), + ); + }); + + it("revalidates the completed graph references", () => { + const { graph, proposal } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal, + }); + + expect(result.success).toBe(true); + expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({ + valid: true, + errors: [], + }); + }); + + it("is atomic on failure", () => { + const { graph, proposal } = makeApplicationFixture(); + const originalGraph = JSON.parse(JSON.stringify(graph)); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + ...proposal, + addedEdges: [ + makeEdge({ + id: "e-bad", + fromNodeId: "missing-node", + toNodeId: "n-quality-deterioration", + relationship: "supports", + confidence: "medium", + description: "Invalid edge", + }), + ], + }, + }); + + expect(result.success).toBe(false); + expect(graph).toEqual(originalGraph); + }); + + it("rejects a proposal with no meaningful change", () => { + const { graph } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + addedNodes: [], + updatedNodes: [ + { + nodeId: "n-quality-deterioration", + previousStatus: null, + newStatus: null, + previousValue: null, + newValue: null, + reason: "No change", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: [], + }, + }); + + expect(result).toMatchObject({ + success: false, + stage: "proposal_compatibility", + }); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining("no meaningful change")]), + ); + }); +}); diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js index e863fe8..18c48b2 100644 --- a/tests/graph/orchestrator.test.js +++ b/tests/graph/orchestrator.test.js @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { validateGraphReferences } from "@/lib/graph/utils.js"; import { makeGraph, makeNode } from "@/lib/graph/schema.js"; const mockAnalyseScenario = vi.fn(); @@ -526,4 +527,123 @@ describe("lib/graph/orchestrator startCase", () => { expect(result.nextQuestion).toBeUndefined(); expect(result.proposal.nextQuestion).toBeUndefined(); }); + + it("defaults to proposal-only mode", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const applyValidatedProposal = vi.fn(); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), + }; + + const result = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + applyValidatedProposal, + }); + + expect(result.success).toBe(true); + expect(result.stage).toBe("proposal_ready"); + expect(applyValidatedProposal).not.toHaveBeenCalled(); + }); + + it("applies the proposal only when explicitly enabled", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const request = makeUpdateRequest({ + situationGraph: makeGraph({ + centralStatement: + "Complaint counts increased while production also increased.", + nodes: [ + makeNode({ + id: "n-rate", + label: "Complaint rate", + description: "Need complaint rate", + kind: "unknown", + status: "unknown", + confidence: "high", + affects: ["n-conclusion"], + }), + makeNode({ + id: "n-other-unknown", + label: "Other unknown", + description: "Another unresolved unknown", + kind: "unknown", + status: "unknown", + confidence: "medium", + }), + makeNode({ + id: "n-conclusion", + label: "Quality deterioration", + description: "Quality conclusion", + kind: "conclusion", + status: "supported", + confidence: "medium", + dependsOn: ["n-rate"], + }), + ], + edges: [], + activeUnknownNodeId: "n-rate", + resolvedNodeIds: [], + currentSummary: "Initial summary", + }), + }); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue({ + addedNodes: [], + updatedNodes: [ + { + nodeId: "n-rate", + previousStatus: "unknown", + newStatus: "resolved", + previousValue: "2.0 complaints per 100 units", + newValue: "1.9 complaints per 100 units", + reason: "The answer provides the updated rate.", + }, + { + nodeId: "n-conclusion", + previousStatus: "supported", + newStatus: "weakened", + previousValue: null, + newValue: null, + reason: "The updated rate weakens the conclusion.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-rate"], + affectedNodeIds: ["n-conclusion"], + }), + }; + + const result = await updateCase(request, { + provider, + config: MOCK_CONFIG, + applyProposal: true, + }); + + expect(result).toMatchObject({ + success: true, + stage: "update_applied", + affectedNodeIds: expect.arrayContaining(["n-rate", "n-conclusion"]), + resolvedUnknownNodeIds: ["n-rate"], + previousActiveUnknownNodeId: "n-rate", + newActiveUnknownNodeId: "n-other-unknown", + }); + expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({ + valid: true, + errors: [], + }); + }); + + it("startCase behaviour remains unchanged", async () => { + mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ scenario: "Scenario text" }); + + expect(result.success).toBe(true); + expect(result.selectedQuestion).toEqual({ + id: "q-1", + question: "What denominator is being used for the complaint rate?", + }); + }); });