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(); const MOCK_CONFIG = { OLLAMA_MODEL: "configured" }; vi.mock("@/lib/analysis.js", () => ({ analyseScenario: (...args) => mockAnalyseScenario(...args), })); function makeAnalysisResult(overrides = {}) { return { success: true, validationStatus: "valid", modelName: "configured-model", responseDurationMs: 321, rawResponse: undefined, promptVersion: "v0.3", reconstruction: { summary: "Revenue and complaints diverge", actors: [], systemsOrObjects: [], expectedStates: [], observedStates: [ { id: "obs-1", label: "Revenue up", description: "Revenue up 15%", confidence: "high", }, ], differences: [], knownTransitions: [], unexplainedTransitions: [], contradictions: [], importantUnknowns: [ { id: "unk-1", label: "Complaint rate denominator", description: "Need the denominator for complaint rate", confidence: "high", }, ], plausibleInterpretations: [], }, evidence: [], nextQuestion: { id: "q-1", question: "What denominator is being used for the complaint rate?", }, compatibilityApplied: false, compatibilityChanges: [], compatibilityWarnings: [], ...overrides, }; } function makeUpdateGraph() { const unknown = makeNode({ id: "n-unknown", label: "Complaint rate denominator", description: "Need the denominator for the complaint rate", kind: "unknown", status: "unknown", confidence: "high", }); const observation = makeNode({ id: "n-observation", label: "Complaint count rose", description: "Complaint count rose faster than output", kind: "observation", status: "supported", confidence: "high", }); return makeGraph({ centralStatement: "Complaint counts increased while production also increased.", nodes: [unknown, observation], edges: [], activeUnknownNodeId: unknown.id, resolvedNodeIds: [], currentSummary: "Nodes: 1 unknown, 1 observation | Edges: 0 total", }); } function makeUpdateRequest(overrides = {}) { return { situationGraph: makeUpdateGraph(), previousQuestion: "What denominator is being used for the complaint rate?", answer: "The complaint rate fell from 2.0 complaints per 100 units to 1.9 complaints per 100 units.", promptVersion: "v0.4", ...overrides, }; } function makeProposal(overrides = {}) { return { addedNodes: [], updatedNodes: [ { nodeId: "n-unknown", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "1.9 complaints per 100 units", reason: "The answer directly provides the normalized rate.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-unknown"], affectedNodeIds: [], selectedQuestion: null, ...overrides, }; } describe("lib/graph/orchestrator startCase", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); }); it("passes a valid request through to analyseScenario", async () => { mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Revenue increased while complaint counts rose faster.", promptVersion: "v0.3", }); expect(result.success).toBe(true); expect(mockAnalyseScenario).toHaveBeenCalledWith( "Revenue increased while complaint counts rose faster.", { promptVersion: "v0.3" }, ); }); it("rejects invalid request input without throwing", async () => { const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "" }); expect(result).toMatchObject({ success: false, error: "Invalid start-case request", statusCode: 400, }); expect(result.validationErrors).toBeInstanceOf(Array); expect(mockAnalyseScenario).not.toHaveBeenCalled(); }); it("builds a valid graph on successful analysis", 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.situationGraph.centralStatement).toBe("Scenario text"); expect(result.situationGraph.currentSummary).toContain("Nodes:"); expect(result.diagnostics).toMatchObject({ validationStatus: "valid", modelName: "configured-model", graphReferenceValidation: { valid: true, errors: [] }, }); }); it("applies active unknown selection to the graph", 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.situationGraph.activeUnknownNodeId).toBeTruthy(); expect(result.diagnostics.unknownSelectionExplanation?.selectedNodeId).toBe( result.situationGraph.activeUnknownNodeId, ); }); it("returns an ambiguous tie result instead of choosing by label order", async () => { mockAnalyseScenario.mockResolvedValue( makeAnalysisResult({ reconstruction: { summary: "Revenue up while cash falls", actors: [], systemsOrObjects: [], expectedStates: [], observedStates: [ { id: "obs-1", label: "Revenue increased by 18%.", description: "Revenue increased by 18%.", confidence: "high", }, { id: "obs-2", label: "Cash in the bank decreased over the same period.", description: "Cash in the bank decreased over the same period.", confidence: "high", }, ], differences: [], knownTransitions: [], unexplainedTransitions: [], contradictions: [ { id: "c-1", label: "Apparent contradiction between profitability/revenue expansion and liquidity reduction.", description: "Apparent contradiction between profitability/revenue expansion and liquidity reduction.", confidence: "medium", }, ], importantUnknowns: [ { id: "unk-1", label: "Whether revenue recognition timing differs from cash collection timing.", description: "Whether revenue recognition timing differs from cash collection timing.", confidence: "high", }, { id: "unk-2", label: "Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).", description: "Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).", confidence: "high", }, ], plausibleInterpretations: [], }, }), ); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Revenue increased by 18%, but cash in the bank fell over the same period.", }); expect(result.success).toBe(true); expect(result.situationGraph.activeUnknownNodeId).toBeNull(); expect(result.selectedQuestion).toMatchObject({ id: "q_tie_resolution", selectionStatus: "ambiguous", question: "What changed during the period that could explain why Revenue increased by 18%, but cash in the bank fell over the same period?", tiedCandidateIds: expect.arrayContaining([expect.any(String)]), }); expect(result.diagnostics.unknownSelectionExplanation).toMatchObject({ status: "ambiguous", tieType: "complete_unresolved_tie", selectedNodeId: null, alphabeticalUsedAsReasoning: false, }); }); it("returns structured failure when graph reference validation fails", async () => { mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); const utils = await import("@/lib/graph/utils.js"); const validateSpy = vi .spyOn(utils, "validateGraphReferences") .mockReturnValue({ valid: false, errors: ['Edge references non-existent toNodeId "missing"'], }); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result).toMatchObject({ success: false, error: "Situation graph reference validation failed", validationErrors: ['Edge references non-existent toNodeId "missing"'], statusCode: 500, }); expect(result.diagnostics.graphReferenceValidation.valid).toBe(false); validateSpy.mockRestore(); }); it("preserves analysis/provider failure details", async () => { mockAnalyseScenario.mockResolvedValue({ success: false, error: "Provider unavailable", errors: ["socket hang up"], rawResponse: null, modelName: "configured-model", responseDurationMs: 99, promptVersion: "v0.3", validationStatus: "invalid", statusCode: 502, }); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result).toMatchObject({ success: false, error: "Provider unavailable", analysisErrors: ["socket hang up"], statusCode: 502, }); }); it("returns null selectedQuestion when analysis has no nextQuestion", async () => { mockAnalyseScenario.mockResolvedValue( makeAnalysisResult({ nextQuestion: undefined }), ); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result.success).toBe(true); expect(result.selectedQuestion).toBeNull(); }); it("includes compatibility diagnostics when provided by analysis", async () => { mockAnalyseScenario.mockResolvedValue( makeAnalysisResult({ compatibilityApplied: true, compatibilityChanges: [ { path: ["evidence", 0, "source"], change: "Converted null source to undefined", }, ], compatibilityWarnings: [ "Applied deterministic reconstruction compatibility normalisation", ], }), ); const { startCase } = await import("@/lib/graph/orchestrator.js"); const result = await startCase({ scenario: "Scenario text" }); expect(result.diagnostics.compatibilityApplied).toBe(true); expect(result.diagnostics.compatibilityChanges).toHaveLength(1); }); it("preserves selected question bytes while adding selection explanation diagnostics", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue( makeProposal({ addedNodes: [ makeNode({ id: "n-build-decision", label: "Build Confidence Engine decision", description: "Decision introduced by the answer.", kind: "state", status: "supported", confidence: "medium", }), makeNode({ id: "n-commercial-value", label: "Commercial value definition", description: "Need a concrete definition because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", }), ], addedEdges: [ { id: "e-build-commercial-value", fromNodeId: "n-build-decision", toNodeId: "n-commercial-value", relationship: "depends_on", confidence: "medium", description: "The decision depends on commercial value definition.", }, ], selectedQuestion: { nodeId: "n-commercial-value", question: "How should commercial value be defined for this decision?", reason: "Consequential unresolved uncertainty remains.", }, }), ), }; const first = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, applyProposal: true, }); const second = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, applyProposal: true, }); expect(first.selectedQuestion.question).toBe( second.selectedQuestion.question, ); expect(first.selectedQuestion.reason).toBe(second.selectedQuestion.reason); expect(first.diagnostics.unknownSelectionExplanation).toBeTruthy(); }); it("produces a validated update proposal for a valid request", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result).toMatchObject({ success: true, stage: "proposal_ready", proposal: makeProposal(), diagnostics: { promptVersion: "v0.4", modelName: "configured", nodeCount: 2, edgeCount: 0, validationStatus: "valid", }, }); expect(provider.generateReconstruction).toHaveBeenCalledTimes(1); }); it("valid request reaches prompt builder", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const buildGraphUpdatePrompt = vi.fn().mockReturnValue("PROMPT"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const request = makeUpdateRequest(); const result = await updateCase(request, { buildGraphUpdatePrompt, provider, config: MOCK_CONFIG, }); expect(result.success).toBe(true); expect(buildGraphUpdatePrompt).toHaveBeenCalledWith({ situationGraph: request.situationGraph, previousQuestion: request.previousQuestion, answer: request.answer, promptVersion: request.promptVersion, }); expect(provider.generateReconstruction).toHaveBeenCalledWith( "PROMPT", "configured", ); }); it("invalid request prevents provider call", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn(), }; const result = await updateCase( { previousQuestion: "Q?", answer: "A" }, { provider, config: MOCK_CONFIG, }, ); expect(result).toMatchObject({ success: false, stage: "request_validation", error: "Invalid update-case request", statusCode: 400, }); expect(result.validationErrors).toBeInstanceOf(Array); expect(provider.generateReconstruction).not.toHaveBeenCalled(); }); it("invalid graph prevents provider call", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn(), }; const graph = makeUpdateGraph(); graph.nodes[0].dependsOn.push("missing-node"); const result = await updateCase( makeUpdateRequest({ situationGraph: graph }), { provider, config: MOCK_CONFIG, }, ); expect(result).toMatchObject({ success: false, stage: "graph_validation", error: "Invalid situation graph", statusCode: 400, }); expect(result.graphValidationErrors).toEqual( expect.arrayContaining([ expect.stringContaining('depends on "missing-node"'), ]), ); expect(provider.generateReconstruction).not.toHaveBeenCalled(); }); it("prompt includes previous question and answer", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const request = makeUpdateRequest(); await updateCase(request, { provider, config: MOCK_CONFIG, }); const prompt = provider.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain(request.previousQuestion); expect(prompt).toContain(request.answer); }); it("returns proposal validation failure for malformed JSON", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue("{not json"), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result).toMatchObject({ success: false, stage: "proposal_validation", error: "Invalid graph update proposal", diagnostics: { promptVersion: "v0.4", modelName: "configured", }, statusCode: 502, }); expect(result.proposalErrors).toBeInstanceOf(Array); }); it("returns structured errors for schema-invalid proposal", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue({ updatedNodes: [{ nodeId: "n-unknown" }], }), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_validation"); expect(result.proposalErrors).toEqual( expect.arrayContaining([ expect.objectContaining({ path: expect.any(Array), message: expect.any(String), }), ]), ); }); it("includes parser normalisations in diagnostics", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue({ updatedNodes: [], }), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result.success).toBe(true); expect(result.diagnostics.normalisationsApplied).toEqual( expect.arrayContaining([ expect.objectContaining({ change: "Filled missing optional array with []", }), ]), ); }); it("returns structured provider-stage failure", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi .fn() .mockRejectedValue(new Error("provider offline")), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result).toMatchObject({ success: false, stage: "provider", error: "Graph update proposal generation failed", providerErrors: ["provider offline"], statusCode: 502, }); }); it("does not mutate the input graph", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const request = makeUpdateRequest(); const originalGraph = JSON.parse(JSON.stringify(request.situationGraph)); await updateCase(request, { provider, config: MOCK_CONFIG, }); expect(request.situationGraph).toEqual(originalGraph); }); it("does not call applyGraphUpdate", async () => { const utils = await import("@/lib/graph/utils.js"); const applySpy = vi.spyOn(utils, "applyGraphUpdate"); const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(applySpy).not.toHaveBeenCalled(); applySpy.mockRestore(); }); it("does not invent a next question outside the proposal", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, }); expect(result.selectedQuestion).toBeUndefined(); expect(result.nextQuestion).toBeUndefined(); expect(result.proposal.nextQuestion).toBeUndefined(); }); it("returns selectedQuestion from applied update proposal", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue( makeProposal({ addedNodes: [ makeNode({ id: "n-build-decision", label: "Build Confidence Engine decision", description: "Decision introduced by the answer.", kind: "state", status: "supported", confidence: "medium", }), makeNode({ id: "n-commercial-value", label: "Commercial value definition", description: "Need a concrete definition because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", }), ], addedEdges: [ { id: "e-build-commercial-value", fromNodeId: "n-build-decision", toNodeId: "n-commercial-value", relationship: "depends_on", confidence: "medium", description: "The decision depends on commercial value definition.", }, ], selectedQuestion: { nodeId: "n-commercial-value", question: "How should commercial value be defined for this decision?", reason: "Consequential unresolved uncertainty remains.", }, }), ), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, applyProposal: true, }); expect(result.success).toBe(true); expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value"); expect(result.newActiveUnknownNodeId).toBe("n-commercial-value"); expect(result.selectedQuestion?.question).not.toBe( "How should commercial value be defined for this decision?", ); expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( "how should uncertainty regarding", ); }); it("deterministically prioritises customer value over pricing follow-up", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = { generateReconstruction: vi.fn().mockResolvedValue( makeProposal({ addedNodes: [ makeNode({ id: "n-value", label: "Customer value", description: "Need customer value because purchase decisions depend on it.", kind: "unknown", status: "unknown", confidence: "high", }), makeNode({ id: "n-price", label: "Target price point", description: "Need a target price point because revenue assumptions depend on it.", kind: "unknown", status: "unknown", confidence: "medium", dependsOn: ["n-value"], }), makeNode({ id: "n-decision", label: "Build Confidence Engine decision", description: "Decision introduced by the answer.", kind: "state", status: "supported", confidence: "medium", }), ], addedEdges: [ { id: "e-decision-value", fromNodeId: "n-decision", toNodeId: "n-value", relationship: "depends_on", confidence: "medium", description: "The decision depends on customer value.", }, { id: "e-value-price", fromNodeId: "n-value", toNodeId: "n-price", relationship: "depends_on", confidence: "medium", description: "Pricing depends on customer value.", }, { id: "e-decision-price", fromNodeId: "n-decision", toNodeId: "n-price", relationship: "depends_on", confidence: "low", description: "The decision references pricing assumptions.", }, ], selectedQuestion: { nodeId: "n-price", question: "What is the price point?", reason: "Model chose pricing.", }, }), ), }; const result = await updateCase(makeUpdateRequest(), { provider, config: MOCK_CONFIG, applyProposal: true, }); expect(result.success).toBe(true); expect(result.selectedQuestion?.nodeId).toBe("n-value"); }); 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?", }); }); });