feat: propagate child resolution through reasoning graph

This commit is contained in:
2026-08-03 06:52:52 +01:00
parent d52690cf2b
commit 49765e95a0
4 changed files with 829 additions and 8 deletions
+392
View File
@@ -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" });
});
});