feat: decompose composite unknowns before questioning
This commit is contained in:
@@ -1146,10 +1146,10 @@ describe("applyValidatedProposal", () => {
|
||||
comparabilityUnknownId,
|
||||
]);
|
||||
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
||||
expect(result.selectedQuestion?.question).toMatch(
|
||||
/^What changed during that period that could help explain why /,
|
||||
);
|
||||
expect(result.selectedQuestion?.question).not.toContain("same basis");
|
||||
expect(result.selectedQuestion).toMatchObject({
|
||||
nodeId: result.newActiveUnknownNodeId,
|
||||
question: "What evidence would clarify timing or measurement basis?",
|
||||
});
|
||||
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
||||
/dso|debtor days|receivables turnover|working capital|receivables/,
|
||||
);
|
||||
@@ -1224,12 +1224,97 @@ describe("applyValidatedProposal", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.emergentReasoningNodeCreated).toBe(false);
|
||||
expect(result.emergentReasoningNodeId).toBe("n-existing-explanation");
|
||||
expect(result.newActiveUnknownNodeId).toBe("n-existing-explanation");
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-existing-explanation");
|
||||
expect(result.newActiveUnknownNodeId).not.toBe("n-existing-explanation");
|
||||
expect(result.selectedQuestion?.nodeId).not.toBe("n-existing-explanation");
|
||||
expect(result.selectedQuestion?.question).toBe(
|
||||
"What evidence would clarify timing or measurement basis?",
|
||||
);
|
||||
expect(
|
||||
result.updatedSituationGraph.nodes.filter(
|
||||
(node) => node.label === graph.nodes.at(-1).label,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("decomposes a composite selected unknown before asking the next question", () => {
|
||||
const { graph, proposal } = makeComparabilityUpdateFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal,
|
||||
previousQuestion:
|
||||
"Were these figures measured on the same basis and at the same scale?",
|
||||
answer:
|
||||
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.atomicityAssessment).toBe("composite");
|
||||
expect(result.decompositionPerformed).toBe(true);
|
||||
expect(result.childUnknownCount).toBe(5);
|
||||
expect(result.childNodeIds).toHaveLength(5);
|
||||
expect(result.atomicityReason).toContain("Decomposed");
|
||||
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
||||
expect(result.selectedQuestion?.nodeId).not.toBe(
|
||||
result.emergentReasoningNodeId,
|
||||
);
|
||||
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
||||
/dso|working capital|receivables|capex/,
|
||||
);
|
||||
|
||||
const parentNode = result.updatedSituationGraph.nodes.find(
|
||||
(node) => node.id === result.emergentReasoningNodeId,
|
||||
);
|
||||
expect(parentNode?.status).toBe("unknown");
|
||||
|
||||
const childNodes = result.updatedSituationGraph.nodes.filter((node) =>
|
||||
result.childNodeIds.includes(node.id),
|
||||
);
|
||||
expect(childNodes).toHaveLength(5);
|
||||
expect(childNodes.every((node) => node.parentId === parentNode.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
result.updatedSituationGraph.edges.filter(
|
||||
(edge) =>
|
||||
result.childNodeIds.includes(edge.fromNodeId) &&
|
||||
edge.toNodeId === parentNode.id &&
|
||||
edge.relationship === "depends_on",
|
||||
),
|
||||
).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("reuses existing decomposition children instead of duplicating them", () => {
|
||||
const { graph, proposal } = makeComparabilityUpdateFixture();
|
||||
|
||||
const firstResult = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal,
|
||||
previousQuestion:
|
||||
"Were these figures measured on the same basis and at the same scale?",
|
||||
answer:
|
||||
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||
});
|
||||
|
||||
expect(firstResult.success).toBe(true);
|
||||
|
||||
const secondResult = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal,
|
||||
previousQuestion:
|
||||
"Were these figures measured on the same basis and at the same scale?",
|
||||
answer:
|
||||
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||
});
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
expect(secondResult.atomicityAssessment).toBe("composite");
|
||||
const uniqueChildIds = new Set(firstResult.childNodeIds);
|
||||
expect(uniqueChildIds.size).toBe(firstResult.childNodeIds.length);
|
||||
expect(
|
||||
secondResult.updatedSituationGraph.nodes.filter((node) =>
|
||||
firstResult.childNodeIds.includes(node.id),
|
||||
),
|
||||
).toHaveLength(firstResult.childNodeIds.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assessUnknownAtomicity } from "@/lib/graph/question-formulator.js";
|
||||
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||
|
||||
function makeGraphWithUnknown(centralStatement, unknown, observations = []) {
|
||||
return makeGraph({
|
||||
centralStatement,
|
||||
nodes: [unknown, ...observations],
|
||||
edges: [],
|
||||
activeUnknownNodeId: unknown.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Atomicity test graph",
|
||||
});
|
||||
}
|
||||
|
||||
describe("assessUnknownAtomicity", () => {
|
||||
it("classifies denominator-style unknowns as atomic", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-denominator",
|
||||
label: "Complaint rate denominator",
|
||||
description:
|
||||
"Need the denominator because it directly determines the complaint rate.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const result = assessUnknownAtomicity({
|
||||
node: unknown,
|
||||
graph: makeGraphWithUnknown(
|
||||
"Production increased while complaints increased.",
|
||||
unknown,
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.atomicity).toBe("atomic");
|
||||
expect(result.reason.toLowerCase()).toContain("directly");
|
||||
});
|
||||
|
||||
it("classifies relationship explanation unknowns as composite", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-explanation",
|
||||
label:
|
||||
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||
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 graph = makeGraphWithUnknown(
|
||||
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||
unknown,
|
||||
[
|
||||
makeNode({
|
||||
id: "n-revenue",
|
||||
label: "Revenue increased by 18%.",
|
||||
description: "Revenue increased by 18%.",
|
||||
kind: "observation",
|
||||
status: "supported",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-cash",
|
||||
label: "Cash in the bank decreased over the same period.",
|
||||
description: "Cash in the bank decreased over the same period.",
|
||||
kind: "observation",
|
||||
status: "supported",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const result = assessUnknownAtomicity({ node: unknown, graph });
|
||||
|
||||
expect(result.atomicity).toBe("composite");
|
||||
expect(result.decompositionKind).toBe("relationship_explanation");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"Customer satisfaction rose, but complaints also rose.",
|
||||
"Explanation for why customer satisfaction rose, but complaints also rose",
|
||||
],
|
||||
[
|
||||
"Delivery time fell, but cancellations increased.",
|
||||
"Possible causes of why delivery time fell, but cancellations increased",
|
||||
],
|
||||
[
|
||||
"Traffic increased, but sales stayed flat.",
|
||||
"Broad explanation for why traffic increased, but sales stayed flat",
|
||||
],
|
||||
[
|
||||
"Production increased, but defects also increased.",
|
||||
"Factors behind why production increased, but defects also increased",
|
||||
],
|
||||
])(
|
||||
"classifies broad divergence unknowns as composite: %s",
|
||||
(scenario, label) => {
|
||||
const unknown = makeNode({
|
||||
id: `n-${label.length}`,
|
||||
label,
|
||||
description: `${label} because the current unknown is too broad to ask directly.`,
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const result = assessUnknownAtomicity({
|
||||
node: unknown,
|
||||
graph: makeGraphWithUnknown(scenario, unknown),
|
||||
});
|
||||
|
||||
expect(result.atomicity).toBe("composite");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1047,8 +1047,13 @@ describe("lib/graph/orchestrator startCase", () => {
|
||||
relationshipAssessed: true,
|
||||
resolvedReasoningNodeIds: ["reasoning:comparability"],
|
||||
emergentReasoningNodeCreated: true,
|
||||
atomicityAssessment: "composite",
|
||||
decompositionPerformed: true,
|
||||
childUnknownCount: 5,
|
||||
});
|
||||
expect(result.diagnostics.emergentReasoningNodeId).toBeTruthy();
|
||||
expect(result.diagnostics.childNodeIds).toHaveLength(5);
|
||||
expect(result.diagnostics.atomicityReason).toContain("Decomposed");
|
||||
expect(result.diagnostics.emergentReasoningNodeReason).toContain(
|
||||
"backed by the graph",
|
||||
);
|
||||
@@ -1080,8 +1085,8 @@ describe("lib/graph/orchestrator startCase", () => {
|
||||
},
|
||||
]);
|
||||
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
||||
expect(result.selectedQuestion?.question).toMatch(
|
||||
/^What changed during that period that could help explain why /,
|
||||
expect(result.selectedQuestion?.question).toBe(
|
||||
"What evidence would clarify timing or measurement basis?",
|
||||
);
|
||||
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
||||
/same basis|dso|receivables|debtor days|working capital/,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assessUnknownAtomicity,
|
||||
formulateQuestion,
|
||||
formulateTieResolutionQuestion,
|
||||
selectInvestigationStrategy,
|
||||
@@ -18,6 +19,63 @@ function makeGraphFor(node, extra = {}) {
|
||||
}
|
||||
|
||||
describe("formulateQuestion", () => {
|
||||
it("atomicity assessment leaves focused unknowns direct and marks broad explanation unknowns composite", () => {
|
||||
const atomicUnknown = makeNode({
|
||||
id: "n-atomic",
|
||||
label: "Complaint rate denominator",
|
||||
description:
|
||||
"Need the denominator because it directly determines the complaint rate.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const compositeUnknown = makeNode({
|
||||
id: "n-composite",
|
||||
label:
|
||||
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||
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 compositeGraph = makeGraphFor(compositeUnknown, {
|
||||
centralStatement:
|
||||
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||
nodes: [
|
||||
makeNode({
|
||||
id: "n-revenue-observation",
|
||||
label: "Revenue increased by 18%.",
|
||||
description: "Revenue increased by 18%.",
|
||||
kind: "observation",
|
||||
status: "supported",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-cash-observation",
|
||||
label: "Cash in the bank decreased over the same period.",
|
||||
description: "Cash in the bank decreased over the same period.",
|
||||
kind: "observation",
|
||||
status: "supported",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
assessUnknownAtomicity({
|
||||
node: atomicUnknown,
|
||||
graph: makeGraphFor(atomicUnknown),
|
||||
}).atomicity,
|
||||
).toBe("atomic");
|
||||
expect(
|
||||
assessUnknownAtomicity({
|
||||
node: compositeUnknown,
|
||||
graph: compositeGraph,
|
||||
}).atomicity,
|
||||
).toBe("composite");
|
||||
});
|
||||
|
||||
it("commercial viability plus build decision produces a decision-threshold question", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-commercial",
|
||||
|
||||
Reference in New Issue
Block a user