420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
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",
|
|
},
|
|
});
|
|
});
|
|
});
|