feat: decompose composite unknowns before questioning

This commit is contained in:
2026-08-03 06:32:12 +01:00
parent 0723c2f49a
commit d52690cf2b
8 changed files with 874 additions and 131 deletions
+4 -3
View File
@@ -1148,7 +1148,8 @@ describe("applyValidatedProposal", () => {
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
expect(result.selectedQuestion).toMatchObject({
nodeId: result.newActiveUnknownNodeId,
question: "What evidence would clarify timing or measurement basis?",
question:
"What evidence would clarify how the two observations were measured?",
});
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
/dso|debtor days|receivables turnover|working capital|receivables/,
@@ -1227,7 +1228,7 @@ describe("applyValidatedProposal", () => {
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?",
"What evidence would clarify how the two observations were measured?",
);
expect(
result.updatedSituationGraph.nodes.filter(
@@ -1253,7 +1254,7 @@ describe("applyValidatedProposal", () => {
expect(result.decompositionPerformed).toBe(true);
expect(result.childUnknownCount).toBe(5);
expect(result.childNodeIds).toHaveLength(5);
expect(result.atomicityReason).toContain("Decomposed");
expect(result.atomicityReason).toBeTruthy();
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
expect(result.selectedQuestion?.nodeId).not.toBe(
result.emergentReasoningNodeId,
+278
View File
@@ -0,0 +1,278 @@
import { describe, expect, it } from "vitest";
import {
assessChildUnknownQuality,
applyValidatedProposal,
MAX_DECOMPOSITION_DEPTH,
} from "@/lib/graph/apply-proposal.js";
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
function makeParentGraph({
centralStatement,
parentLabel,
parentDescription,
observations = [],
}) {
const parent = makeNode({
id: "n-parent",
label: parentLabel,
description: parentDescription,
kind: "unknown",
status: "unknown",
confidence: "medium",
});
return {
parent,
graph: makeGraph({
centralStatement,
nodes: [parent, ...observations],
edges: [],
activeUnknownNodeId: parent.id,
resolvedNodeIds: [],
currentSummary: "Decomposition quality graph",
}),
};
}
describe("assessChildUnknownQuality", () => {
it("rejects 'Timing or measurement basis' as compound", () => {
const { parent, graph } = makeParentGraph({
centralStatement:
"Revenue increased by 18%, but cash in the bank fell over the same period.",
parentLabel:
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
parentDescription:
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
});
const child = makeNode({
id: "n-child",
label: "Timing or measurement basis",
description:
"Need evidence about whether a timing or measurement-basis difference could explain the observations, because that would change how they should be interpreted.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: parent.id,
});
const result = assessChildUnknownQuality({
parentNode: parent,
childNode: child,
siblingNodes: [child],
graph,
});
expect(result.valid).toBe(false);
expect(result.compoundSignals).toContain("timing_or_measurement_basis");
expect(result.reasons).toContain("compound_child");
});
it("accepts a child with one directly answerable uncertainty", () => {
const { parent, graph } = makeParentGraph({
centralStatement: "Traffic increased, but sales stayed flat.",
parentLabel:
"What explains why more website traffic did not produce more sales?",
parentDescription:
"Need an explanation because the observations moved differently.",
});
const child = makeNode({
id: "n-child",
label: "Different measurement basis between the two observations",
description:
"Need evidence about whether the two observations use different measurement bases, because that could help explain the difference.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: parent.id,
});
const result = assessChildUnknownQuality({
parentNode: parent,
childNode: child,
siblingNodes: [child],
graph,
});
expect(result.valid).toBe(true);
expect(result.atomic).toBe(true);
expect(result.directlyAnswerable).toBe(true);
expect(result.narrowerThanParent).toBe(true);
});
it("rejects sibling duplicates", () => {
const { parent, graph } = makeParentGraph({
centralStatement: "Production increased, but defects also increased.",
parentLabel: "What explains why output and defects both increased?",
parentDescription:
"Need an explanation because both observations increased.",
});
const childA = makeNode({
id: "n-child-a",
label: "Different timing between the two observations",
description:
"Need evidence about whether the two observations reflect different timing, because that could help explain the difference.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: parent.id,
});
const childB = makeNode({
id: "n-child-b",
label: "Different timing between the two observations",
description:
"Need evidence about whether the two observations reflect different timing, because that could help explain the difference.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: parent.id,
});
const result = assessChildUnknownQuality({
parentNode: parent,
childNode: childA,
siblingNodes: [childA, childB],
graph,
});
expect(result.valid).toBe(false);
expect(result.duplicateSiblingIds).toContain("n-child-b");
});
it("rejects parent paraphrases", () => {
const { parent, graph } = makeParentGraph({
centralStatement:
"Customer satisfaction scores increased, but complaints also increased.",
parentLabel:
"What explains why satisfaction and complaints both increased?",
parentDescription:
"Need a broad explanation because the observations moved differently.",
});
const child = makeNode({
id: "n-child",
label: "What explains why satisfaction and complaints both increased?",
description:
"Need a broad explanation because the observations moved differently.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: parent.id,
});
const result = assessChildUnknownQuality({
parentNode: parent,
childNode: child,
siblingNodes: [child],
graph,
});
expect(result.valid).toBe(false);
expect(result.reasons).toContain("not_narrower_than_parent");
});
});
describe("decomposition stopping conditions", () => {
function makeMeaningfulNoOpProposal() {
return {
addedNodes: [
makeNode({
id: "n-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: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
selectedQuestion: null,
};
}
it("does not decompose an atomic selected unknown", () => {
const atomic = makeNode({
id: "n-atomic",
label: "Were both figures measured over the same accounting period?",
description:
"Need to know whether both figures cover the same accounting period because that determines whether they are directly comparable.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const graph = makeGraph({
centralStatement:
"Revenue increased by 18%, but cash in the bank fell over the same period.",
nodes: [atomic],
edges: [],
activeUnknownNodeId: atomic.id,
resolvedNodeIds: [],
currentSummary: "Atomic selected node graph",
});
const result = applyValidatedProposal({
situationGraph: graph,
proposal: makeMeaningfulNoOpProposal(),
});
expect(result.success).toBe(true);
expect(result.decompositionAttempted).toBe(false);
expect(result.decompositionStoppedReason).toBe(
"Selected unknown is already atomic.",
);
});
it("stops once a directly answerable child is selected", () => {
const { parent, graph } = makeParentGraph({
centralStatement: "Traffic increased, but sales stayed flat.",
parentLabel:
"What explains why more website traffic did not produce more sales?",
parentDescription:
"Need an explanation because the observations moved differently.",
observations: [
makeNode({
id: "n-traffic",
label: "Website traffic increased.",
description: "Website traffic increased.",
kind: "observation",
status: "supported",
confidence: "high",
}),
makeNode({
id: "n-sales",
label: "Sales stayed flat.",
description: "Sales stayed flat.",
kind: "observation",
status: "supported",
confidence: "high",
}),
],
});
const result = applyValidatedProposal({
situationGraph: graph,
proposal: makeMeaningfulNoOpProposal(),
});
expect(result.success).toBe(true);
expect(result.decompositionAttempted).toBe(true);
expect(result.decompositionAccepted).toBe(true);
expect(result.selectedQuestion).toMatchObject({
nodeId: expect.any(String),
question:
"What evidence would clarify how the two observations were measured?",
});
expect(result.selectedChildNodeId).toBe(result.selectedQuestion?.nodeId);
expect(result.decompositionStoppedReason).toBe(
"Selected child is atomic and directly answerable.",
);
});
it("exposes the configured maximum decomposition depth", () => {
expect(MAX_DECOMPOSITION_DEPTH).toBeGreaterThanOrEqual(2);
expect(MAX_DECOMPOSITION_DEPTH).toBeLessThanOrEqual(3);
});
});
+2 -2
View File
@@ -1053,7 +1053,7 @@ describe("lib/graph/orchestrator startCase", () => {
});
expect(result.diagnostics.emergentReasoningNodeId).toBeTruthy();
expect(result.diagnostics.childNodeIds).toHaveLength(5);
expect(result.diagnostics.atomicityReason).toContain("Decomposed");
expect(result.diagnostics.atomicityReason).toBeTruthy();
expect(result.diagnostics.emergentReasoningNodeReason).toContain(
"backed by the graph",
);
@@ -1086,7 +1086,7 @@ describe("lib/graph/orchestrator startCase", () => {
]);
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
expect(result.selectedQuestion?.question).toBe(
"What evidence would clarify timing or measurement basis?",
"What evidence would clarify how the two observations were measured?",
);
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
/same basis|dso|receivables|debtor days|working capital/,