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/,
+10 -10
View File
@@ -127,9 +127,9 @@ function makeUpdateSuccess(overrides = {}) {
},
{
id: "n-child-1",
label: "Timing or measurement basis",
label: "How the two observations were measured",
description:
"Need evidence about whether a timing or measurement-basis difference could explain revenue increased by 18%, but cash in the bank fell over the same period, because that would change how the observations should be interpreted.",
"Need evidence about the measure used for each observation, because that could help explain revenue increased by 18%, but cash in the bank fell over the same period.",
kind: "unknown",
status: "unknown",
confidence: "medium",
@@ -180,9 +180,9 @@ function makeUpdateSuccess(overrides = {}) {
},
{
id: "n-child-1",
label: "Timing or measurement basis",
label: "How the two observations were measured",
description:
"Need evidence about whether a timing or measurement-basis difference could explain revenue increased by 18%, but cash in the bank fell over the same period, because that would change how the observations should be interpreted.",
"Need evidence about the measure used for each observation, because that could help explain revenue increased by 18%, but cash in the bank fell over the same period.",
kind: "unknown",
status: "unknown",
confidence: "medium",
@@ -205,7 +205,7 @@ function makeUpdateSuccess(overrides = {}) {
selectedQuestion: {
nodeId: "n-child-1",
question:
"What evidence would clarify timing or measurement basis?",
"What evidence would clarify how the two observations were measured?",
reason:
"Formulated from graph context using the evidence_gathering investigation strategy.",
},
@@ -213,7 +213,7 @@ function makeUpdateSuccess(overrides = {}) {
selectedQuestion: {
nodeId: "n-child-1",
question:
"What evidence would clarify timing or measurement basis?",
"What evidence would clarify how the two observations were measured?",
reason:
"Formulated from graph context using the evidence_gathering investigation strategy.",
},
@@ -499,7 +499,7 @@ describe("graph-backed UI rendering", () => {
);
expect(html).toContain(
"What evidence would clarify timing or measurement basis?",
"What evidence would clarify how the two observations were measured?",
);
});
@@ -557,7 +557,7 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("New active unknown");
expect(html).toContain("Next question");
expect(html).toContain(
"What evidence would clarify timing or measurement basis?",
"What evidence would clarify how the two observations were measured?",
);
});
@@ -586,7 +586,7 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("comparability: confirmed");
expect(html).toContain("relationship: insufficient_information");
expect(html).toContain(
"What evidence would clarify timing or measurement basis?",
"What evidence would clarify how the two observations were measured?",
);
expect(html).not.toContain("reasoning:comparability");
});
@@ -613,7 +613,7 @@ describe("graph-backed UI rendering", () => {
);
expect(html).toContain(
"What evidence would clarify timing or measurement basis?",
"What evidence would clarify how the two observations were measured?",
);
});