feat: propagate child resolution through reasoning graph
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
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("high");
|
||||
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: "high",
|
||||
});
|
||||
|
||||
const ancestorNode = result.updatedSituationGraph.nodes.find(
|
||||
(node) => node.id === ids.ancestor,
|
||||
);
|
||||
expect(ancestorNode).toMatchObject({
|
||||
status: "provisional",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user