import { describe, expect, it } from "vitest"; import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; function makePropagationFixture({ key, centralStatement, firstObservationLabel, secondObservationLabel, }) { const parent = makeNode({ id: `${key}-parent`, label: `Explanation for why ${centralStatement}`, description: "Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.", kind: "unknown", status: "unknown", confidence: "medium", }); const measurementChild = makeNode({ id: `${key}-child-measurement`, label: "How the two observations were measured", description: `Need evidence about the measure used for each observation, because that could help explain ${centralStatement}.`, kind: "unknown", status: "unknown", confidence: "medium", parentId: parent.id, }); const timingChild = makeNode({ id: `${key}-child-timing`, label: "Whether the two observations reflect different timing", description: `Need to know whether the two observations reflect different timing, because that could help explain ${centralStatement}.`, kind: "unknown", status: "unknown", confidence: "medium", parentId: parent.id, }); const cashMovementChild = makeNode({ id: `${key}-child-cash-movement`, label: `Possible change mainly affecting ${secondObservationLabel}`, description: `Need to know whether a possible change mainly affected ${secondObservationLabel}, because that could help explain ${centralStatement}.`, kind: "unknown", status: "unknown", confidence: "medium", parentId: parent.id, }); const oneOffChild = makeNode({ id: `${key}-child-one-off`, label: "Possible one-off event during the period", description: `Need to know whether a possible one-off event happened during the period, because that could help explain ${centralStatement}.`, kind: "unknown", status: "unknown", confidence: "medium", parentId: parent.id, }); const ancestor = makeNode({ id: `${key}-ancestor`, label: `Reasoning for ${centralStatement}`, description: "Higher-level reasoning node depending on the parent explanation.", kind: "unknown", status: "unknown", confidence: "medium", childIds: [parent.id], }); const unrelated = makeNode({ id: `${key}-unrelated`, label: "Unrelated branch", description: "Should remain unchanged.", kind: "unknown", status: "unknown", confidence: "low", }); const firstObservation = makeNode({ id: `${key}-obs-1`, label: firstObservationLabel, description: firstObservationLabel, kind: "observation", status: "supported", confidence: "high", }); const secondObservation = makeNode({ id: `${key}-obs-2`, label: secondObservationLabel, description: secondObservationLabel, kind: "observation", status: "supported", confidence: "high", }); const graph = makeGraph({ centralStatement, nodes: [ ancestor, parent, measurementChild, timingChild, cashMovementChild, oneOffChild, unrelated, firstObservation, secondObservation, ], edges: [ makeEdge({ id: `${key}-e-parent-ancestor`, fromNodeId: parent.id, toNodeId: ancestor.id, relationship: "depends_on", description: "Ancestor depends on the parent explanation.", }), makeEdge({ id: `${key}-e-child-measurement-parent`, fromNodeId: measurementChild.id, toNodeId: parent.id, relationship: "depends_on", description: "Measurement child depends into the parent explanation.", }), makeEdge({ id: `${key}-e-child-timing-parent`, fromNodeId: timingChild.id, toNodeId: parent.id, relationship: "depends_on", description: "Timing child depends into the parent explanation.", }), makeEdge({ id: `${key}-e-child-cash-parent`, fromNodeId: cashMovementChild.id, toNodeId: parent.id, relationship: "depends_on", description: "Cash-movement child depends into the parent explanation.", }), makeEdge({ id: `${key}-e-child-one-off-parent`, fromNodeId: oneOffChild.id, toNodeId: parent.id, relationship: "depends_on", description: "One-off child depends into the parent explanation.", }), ], activeUnknownNodeId: measurementChild.id, resolvedNodeIds: [], currentSummary: `Propagation fixture for ${key}`, }); return { graph, ids: { ancestor: ancestor.id, parent: parent.id, measurementChild: measurementChild.id, timingChild: timingChild.id, cashMovementChild: cashMovementChild.id, oneOffChild: oneOffChild.id, unrelated: unrelated.id, }, }; } const scenarios = [ { key: "revenue-cash", centralStatement: "revenue increased while cash fell", firstObservationLabel: "Revenue increased by 18%.", secondObservationLabel: "Cash in the bank fell over the same period.", }, { key: "satisfaction-complaints", centralStatement: "customer satisfaction increased while complaints increased", firstObservationLabel: "Customer satisfaction increased.", secondObservationLabel: "Complaints increased.", }, { key: "traffic-sales", centralStatement: "traffic increased while sales stayed flat", firstObservationLabel: "Website traffic increased.", secondObservationLabel: "Sales stayed flat.", }, { key: "delivery-cancellations", centralStatement: "delivery time fell while cancellations increased", firstObservationLabel: "Average delivery time decreased.", secondObservationLabel: "Cancellations increased.", }, { key: "production-defects", centralStatement: "production increased while defects increased", firstObservationLabel: "Production increased.", secondObservationLabel: "Defects increased.", }, ]; describe("upward propagation", () => { it.each(scenarios)( "propagates resolved measurement child upward for $key", ({ key, centralStatement, firstObservationLabel, secondObservationLabel, }) => { const { graph, ids } = makePropagationFixture({ key, centralStatement, firstObservationLabel, secondObservationLabel, }); const unrelatedBefore = JSON.stringify( graph.nodes.find((node) => node.id === ids.unrelated), ); const result = applyValidatedProposal({ situationGraph: graph, proposal: { addedNodes: [ makeNode({ id: `${key}-anchor`, label: "Update anchor", description: "Anchor state introduced by the answer because the update must contain a meaningful change.", kind: "state", status: "known", confidence: "low", }), ], updatedNodes: [ { nodeId: ids.measurementChild, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "The figures were measured over the same accounting period using the same management accounts.", reason: "The answer resolves the measurement child.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.measurementChild], affectedNodeIds: [], selectedQuestion: null, }, previousQuestion: "What evidence would clarify how the two observations were measured?", answer: "The figures were measured over the same accounting period using the same management accounts.", }); expect(result.success).toBe(true); expect(result.resolvedUnknownNodeIds).toContain(ids.measurementChild); expect(result.propagationPerformed).toBe(true); expect(result.resolvedChildNodeId).toBe(ids.measurementChild); expect(result.parentNodeId).toBe(ids.parent); expect(result.parentStatusBefore).toBe("unknown"); expect(result.parentStatusAfter).toBe("provisional"); expect(result.parentConfidenceBefore).toBe("medium"); expect(result.parentConfidenceAfter).toBe("medium"); expect(result.evidenceConfidenceBefore).toBe("medium"); expect(result.evidenceConfidenceAfter).toBe("medium"); expect(result.completenessBefore).toBe("empty"); expect(result.completenessAfter).toBe("partial"); expect(result.conclusionConfidenceBefore).toBe("low"); expect(result.conclusionConfidenceAfter).toBe("medium"); expect(result.confidenceCapReason).toBe( "unresolved_direct_children_cap_conclusion", ); expect(result.parentResolved).toBe(false); expect(result.affectedAncestorIds).toContain(ids.parent); expect(result.affectedAncestorIds).toContain(ids.ancestor); expect(result.nextSelectedSibling).toBe(result.newActiveUnknownNodeId); expect(result.nextSelectedSibling).toBe(result.selectedQuestion?.nodeId); expect(result.nextSelectedSibling).not.toBe(ids.measurementChild); expect([ ids.timingChild, ids.cashMovementChild, ids.oneOffChild, ]).toContain(result.nextSelectedSibling); expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( "measured", ); const parentNode = result.updatedSituationGraph.nodes.find( (node) => node.id === ids.parent, ); expect(parentNode).toMatchObject({ status: "provisional", confidence: "medium", confidenceAssessment: { evidenceConfidence: "medium", completenessStatus: "partial", conclusionConfidence: "medium", }, }); const ancestorNode = result.updatedSituationGraph.nodes.find( (node) => node.id === ids.ancestor, ); expect(ancestorNode).toMatchObject({ status: "provisional", confidence: "low", confidenceAssessment: { evidenceConfidence: "low", completenessStatus: "empty", conclusionConfidence: "low", }, }); const resolvedChild = result.updatedSituationGraph.nodes.find( (node) => node.id === ids.measurementChild, ); expect(resolvedChild.status).toBe("resolved"); expect(resolvedChild.evidenceIds).toContain( `answer:${ids.measurementChild}`, ); expect( result.updatedSituationGraph.nodes.filter( (node) => node.id === ids.measurementChild, ), ).toHaveLength(1); expect( JSON.stringify( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.unrelated, ), ), ).toBe(unrelatedBefore); }, ); it("resolves the parent only after all direct children are resolved", () => { const { graph, ids } = makePropagationFixture({ key: "completion-rule", centralStatement: "revenue increased while cash fell", firstObservationLabel: "Revenue increased by 18%.", secondObservationLabel: "Cash in the bank fell over the same period.", }); const result = applyValidatedProposal({ situationGraph: graph, proposal: { addedNodes: [ makeNode({ id: "completion-rule-anchor", label: "Update anchor", description: "Anchor state introduced by the answer because the update must contain a meaningful change.", kind: "state", status: "known", confidence: "low", }), ], updatedNodes: [ { nodeId: ids.measurementChild, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "same management accounts", reason: "resolved measurement child", }, { nodeId: ids.timingChild, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "timing aligned", reason: "resolved timing child", }, { nodeId: ids.cashMovementChild, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "cash left through operations", reason: "resolved movement child", }, { nodeId: ids.oneOffChild, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "no exceptional movement", reason: "resolved one-off child", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [ ids.measurementChild, ids.timingChild, ids.cashMovementChild, ids.oneOffChild, ], affectedNodeIds: [], selectedQuestion: null, }, previousQuestion: "What evidence would clarify how the two observations were measured?", answer: "All direct child questions are now answered.", }); expect(result.success).toBe(true); expect(result.parentResolved).toBe(true); expect(result.resolvedUnknownNodeIds).toContain(ids.parent); expect( result.updatedSituationGraph.nodes.find((node) => node.id === ids.parent), ).toMatchObject({ status: "resolved", confidence: "high", confidenceAssessment: { evidenceConfidence: "high", completenessStatus: "complete", conclusionConfidence: "high", }, }); }); });