feat: decompose composite unknowns before questioning

This commit is contained in:
2026-08-02 19:24:38 +01:00
parent b1c633ba5c
commit 0723c2f49a
9 changed files with 768 additions and 25 deletions
+108
View File
@@ -0,0 +1,108 @@
# v0.6 Atomicity Experiment
## Hypothesis
After deterministic unknown selection, the engine should assess whether the selected unknown is already atomic or is still too composite to ask directly.
If the unknown is atomic, the engine should proceed exactly as before.
If the unknown is composite, the engine should not ask that parent unknown directly. Instead, it should decompose it into a small set of explicit child unknowns representing broad, independent candidate dimensions that a non-expert could understand.
## Constraints
- No graph redesign
- No persistence
- No UI redesign
- No selection-weight tuning
- No Ollama calls in unit tests
## Deterministic rule introduced
Atomicity assessment is **not** a new investigation strategy.
It runs in the graph update path at this seam:
```text
unknown selection -> atomicity assessment -> optional decomposition -> deterministic reselection -> question formulation
```
The implementation uses deterministic text and graph-shape checks:
- focused unknowns like denominator / threshold / definition / baseline / evidence remain **atomic**
- broad relationship-explanation unknowns and broad “possible causes / what changed / explanation for why X but Y” unknowns become **composite**
## Decomposition behavior
When a selected unknown is composite:
1. The parent unknown remains unresolved.
2. Between 2 and 5 child unknowns are created or reused deterministically.
3. Children become explicit graph nodes.
4. Children link back to the parent with existing `depends_on` edges.
5. Children inherit the same “why it matters” discipline in their descriptions.
6. Deterministic selection reruns across the updated graph.
For the current relationship-explanation experiment, the broad child dimensions are:
- Timing or measurement basis
- Change affecting signal A more than signal B
- Change affecting signal B more than signal A
- Mix or segment shift
- One-off event during the period
These are intentionally non-jargon and broad enough to generalise across scenarios like:
- Revenue up / Cash down
- Customer satisfaction up / Complaints up
- Delivery time down / Cancellations up
- Traffic up / Sales flat
- Production up / Defects up
## Diagnostics added
The orchestrator now reports:
- `atomicityAssessment`
- `decompositionPerformed`
- `childUnknownCount`
- `childNodeIds`
- `atomicityReason`
This sits alongside the existing explicit-emergent-unknown diagnostics.
## Observed outcome
The experiment was useful.
Before this change, the engine could select a broad explanation unknown and ask it directly.
After this change:
- the broad explanation parent remains explicit in the graph
- the engine decomposes it into child unknowns first
- the next asked question is backed by a more focused child unknown
- repeated updates reuse the same decomposition children deterministically
In the revenue-versus-cash case, the selected next question becomes:
> What evidence would clarify timing or measurement basis?
rather than asking the full broad explanation node directly.
## Interpretation
This supports the idea that recursive decomposition is a fundamental part of graph-backed questioning, not just a prompt refinement.
The main remaining limitation is that the new child set can still produce ties among equally broad dimensions. In the current implementation, that is acceptable because the graph now makes the ambiguity explicit rather than hiding it in a single broad parent question.
## Validation run
Covered by:
- `tests/graph/atomicity-assessment.test.js`
- `tests/graph/apply-proposal.test.js`
- `tests/graph/orchestrator.test.js`
- `tests/graph/question-formulator.test.js`
- `tests/ui/scenario-form.test.jsx`
And then by the broader requested validation pass with lint and build.
+250 -4
View File
@@ -1,5 +1,6 @@
import { describeGraph } from "./builder.js";
import {
assessUnknownAtomicity,
buildReasoningState,
classifyObservationRelationship,
COMPARABILITY_REASONING_NODE_ID,
@@ -550,6 +551,181 @@ function buildEmergentReasoningUnknown(graph, relationshipAssessment) {
};
}
function stripTrailingPunctuation(value) {
return String(value || "")
.trim()
.replace(/[.?!:;]+$/g, "")
.trim();
}
function collectSupportedObservations(graph) {
return (graph.nodes || []).filter(
(node) => node.kind === "observation" && node.status === "supported",
);
}
function detectObservationConcept(text) {
const normalised = normaliseText(text);
const concepts = [
["revenue", /\brevenue\b/],
["cash", /\bcash\b/],
["customer satisfaction", /\bsatisfaction\b/],
["complaints", /\bcomplaints?\b/],
["delivery time", /\bdelivery time\b|\bdelivery\b/],
["cancellations", /\bcancellations?\b/],
["traffic", /\btraffic\b/],
["sales", /\bsales\b/],
["production", /\bproduction\b|\boutput\b/],
["defects", /\bdefects?\b/],
["quality", /\bquality\b/],
];
for (const [label, pattern] of concepts) {
if (pattern.test(normalised)) return label;
}
return null;
}
function buildDecompositionContext(graph) {
const observations = collectSupportedObservations(graph);
const firstObservation = observations[0] ?? null;
const secondObservation = observations[1] ?? null;
const firstConcept = detectObservationConcept(
`${firstObservation?.label || ""} ${firstObservation?.description || ""}`,
);
const secondConcept = detectObservationConcept(
`${secondObservation?.label || ""} ${secondObservation?.description || ""}`,
);
return {
centralStatement: stripTrailingPunctuation(graph.centralStatement),
firstConcept: firstConcept || "the first signal",
secondConcept: secondConcept || "the second signal",
};
}
function buildDecompositionChildId(parentNodeId, label) {
return makeNodeId(`${parentNodeId}:${label}`);
}
function findEquivalentDecompositionChild(
graph,
parentNodeId,
label,
description,
) {
const targetId = buildDecompositionChildId(parentNodeId, label);
const targetTexts = [normaliseText(label), normaliseText(description)].filter(
Boolean,
);
return (graph.nodes || []).find((node) => {
if (
node.kind !== "unknown" ||
node.parentId !== parentNodeId ||
(graph.resolvedNodeIds || []).includes(node.id)
) {
return false;
}
if (node.id === targetId) {
return true;
}
const nodeTexts = [
normaliseText(node.label),
normaliseText(node.description),
].filter(Boolean);
return targetTexts.some((text) => nodeTexts.includes(text));
});
}
function buildCompositeUnknownChildren(parentNode, graph) {
const context = buildDecompositionContext(graph);
const templates = [
{
label: "Timing or measurement basis",
description: `Need evidence about whether a timing or measurement-basis difference could explain ${context.centralStatement}, because that would change how the observations should be interpreted.`,
},
{
label: `Change affecting ${context.firstConcept} more than ${context.secondConcept}`,
description: `Need to know whether something changed that affected ${context.firstConcept} more than ${context.secondConcept}, because that could explain ${context.centralStatement}.`,
},
{
label: `Change affecting ${context.secondConcept} more than ${context.firstConcept}`,
description: `Need to know whether something changed that affected ${context.secondConcept} more than ${context.firstConcept}, because that could explain ${context.centralStatement}.`,
},
{
label: "Mix or segment shift",
description: `Need to know whether the mix of customers, products, orders, or cases changed, because that could explain ${context.centralStatement}.`,
},
{
label: "One-off event during the period",
description: `Need to know whether a one-off event or unusual change happened during the period, because that could explain ${context.centralStatement}.`,
},
];
const childNodes = [];
const childEdges = [];
const childNodeIds = [];
let createdCount = 0;
for (const template of templates) {
const existingNode = findEquivalentDecompositionChild(
graph,
parentNode.id,
template.label,
template.description,
);
const childNode = existingNode || {
id: buildDecompositionChildId(parentNode.id, template.label),
label: template.label,
description: template.description,
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: parentNode.id,
childIds: [],
};
childNodeIds.push(childNode.id);
if (existingNode) {
continue;
}
childNodes.push(childNode);
childEdges.push({
id: `e-${childNode.id.slice(0, 6)}-${parentNode.id.slice(0, 6)}`,
fromNodeId: childNode.id,
toNodeId: parentNode.id,
relationship: "depends_on",
confidence: "medium",
description:
"This child unknown must be investigated before the broader parent explanation can be resolved.",
});
createdCount += 1;
}
return {
childNodes,
childEdges,
childNodeIds,
createdCount,
reason:
createdCount > 0
? "Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question."
: "Reused existing decomposition children for the composite unknown before asking the next question.",
};
}
function isComparabilityQuestion(question) {
const text = String(question || "").toLowerCase();
return (
@@ -795,7 +971,7 @@ export function applyValidatedProposal({
proposalSnapshot.addedEdges.push(...emergentReasoningUnknown.edges);
}
const applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
let applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
if (!applied.success) {
return {
success: false,
@@ -804,13 +980,13 @@ export function applyValidatedProposal({
};
}
const updatedSituationGraph = {
let updatedSituationGraph = {
...graphSnapshot,
nodes: applied.nodes,
edges: applied.edges,
resolvedNodeIds: applied.resolvedNodeIds,
};
const nextReasoningState = buildReasoningState(
let nextReasoningState = buildReasoningState(
updatedSituationGraph,
reasoningResolution.reasoningStateOverride,
);
@@ -846,11 +1022,76 @@ export function applyValidatedProposal({
)?.nodeId ?? null;
}
const deterministicSelection = selectActiveUnknownCandidate(
let deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
let atomicityAssessment = null;
let decompositionPerformed = false;
let decompositionChildNodeIds = [];
let decompositionReason = null;
const initiallySelectedNode =
deterministicSelection?.status === "selected" &&
deterministicSelection?.nodeId
? updatedSituationGraph.nodes.find(
(node) => node.id === deterministicSelection.nodeId,
)
: null;
if (initiallySelectedNode) {
atomicityAssessment = assessUnknownAtomicity({
node: initiallySelectedNode,
graph: updatedSituationGraph,
});
if (atomicityAssessment.atomicity === "composite") {
const decomposition = buildCompositeUnknownChildren(
initiallySelectedNode,
updatedSituationGraph,
);
decompositionPerformed = true;
decompositionChildNodeIds = decomposition.childNodeIds;
decompositionReason =
decomposition.reason || atomicityAssessment.reason || null;
if (
decomposition.childNodes.length > 0 ||
decomposition.childEdges.length > 0
) {
proposalSnapshot.addedNodes.push(...decomposition.childNodes);
proposalSnapshot.addedEdges.push(...decomposition.childEdges);
applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
if (!applied.success) {
return {
success: false,
stage: "application",
errors: applied.errors,
};
}
updatedSituationGraph = {
...graphSnapshot,
nodes: applied.nodes,
edges: applied.edges,
resolvedNodeIds: applied.resolvedNodeIds,
};
nextReasoningState = buildReasoningState(
updatedSituationGraph,
reasoningResolution.reasoningStateOverride,
);
updatedSituationGraph.reasoningState = nextReasoningState;
}
deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
}
}
if (
deterministicSelection?.status === "selected" &&
deterministicSelection?.nodeId
@@ -955,6 +1196,11 @@ export function applyValidatedProposal({
emergentReasoningNodeCreated: Boolean(emergentReasoningUnknown?.created),
emergentReasoningNodeId: emergentReasoningUnknown?.node?.id ?? null,
emergentReasoningNodeReason: emergentReasoningUnknown?.reason ?? null,
atomicityAssessment: atomicityAssessment?.atomicity ?? null,
decompositionPerformed,
childUnknownCount: decompositionChildNodeIds.length,
childNodeIds: decompositionChildNodeIds,
atomicityReason: decompositionReason || atomicityAssessment?.reason || null,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion: finalSelectedQuestion,
+25
View File
@@ -92,6 +92,11 @@ function buildUpdateDiagnostics({
emergentReasoningNodeCreated,
emergentReasoningNodeId,
emergentReasoningNodeReason,
atomicityAssessment,
decompositionPerformed,
childUnknownCount,
childNodeIds,
atomicityReason,
}) {
return {
promptVersion: promptVersion ?? "v0.4",
@@ -120,6 +125,11 @@ function buildUpdateDiagnostics({
emergentReasoningNodeCreated: emergentReasoningNodeCreated ?? false,
emergentReasoningNodeId: emergentReasoningNodeId ?? null,
emergentReasoningNodeReason: emergentReasoningNodeReason ?? null,
atomicityAssessment: atomicityAssessment ?? null,
decompositionPerformed: decompositionPerformed ?? false,
childUnknownCount: childUnknownCount ?? 0,
childNodeIds: childNodeIds ?? [],
atomicityReason: atomicityReason ?? null,
unknownSelectionExplanation: unknownSelectionExplanation ?? null,
};
}
@@ -372,6 +382,11 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
emergentReasoningNodeCreated: false,
emergentReasoningNodeId: null,
emergentReasoningNodeReason: null,
atomicityAssessment: null,
decompositionPerformed: false,
childUnknownCount: 0,
childNodeIds: [],
atomicityReason: null,
unknownSelectionExplanation: explainUnknownSelection(
situationGraph,
situationGraph.resolvedNodeIds || [],
@@ -414,6 +429,11 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
emergentReasoningNodeId: applicationResult.emergentReasoningNodeId,
emergentReasoningNodeReason:
applicationResult.emergentReasoningNodeReason,
atomicityAssessment: applicationResult.atomicityAssessment,
decompositionPerformed: applicationResult.decompositionPerformed,
childUnknownCount: applicationResult.childUnknownCount,
childNodeIds: applicationResult.childNodeIds,
atomicityReason: applicationResult.atomicityReason,
unknownSelectionExplanation: buildUnknownSelectionDiagnostics(
applicationResult.updatedSituationGraph,
applicationResult.updatedSituationGraph.resolvedNodeIds || [],
@@ -441,6 +461,11 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
emergentReasoningNodeCreated: false,
emergentReasoningNodeId: null,
emergentReasoningNodeReason: null,
atomicityAssessment: null,
decompositionPerformed: false,
childUnknownCount: 0,
childNodeIds: [],
atomicityReason: null,
unknownSelectionExplanation: buildUnknownSelectionDiagnostics(
situationGraph,
situationGraph.resolvedNodeIds || [],
+55
View File
@@ -508,6 +508,61 @@ function isRelationshipExplanationUnknown(node, graph) {
);
}
function isBroadCompositeUnknownText(text) {
return /\b(possible causes|possible reasons|root causes|causes of|drivers of|factors behind|factors affecting|what changed|explanation for why|why .* but|difference between|divergence|moved differently|broad explanation|independent dimensions)\b/.test(
text,
);
}
function isFocusedAtomicUnknownText(text) {
return /\b(define|definition|meaning|term|threshold|criterion|criteria|baseline|evidence|measure|metric|denominator|rate|date|period|budget|constraint|customer|actor|owner)\b/.test(
text,
);
}
export function assessUnknownAtomicity({ node, graph }) {
const nodeText = normaliseText(
`${node?.label || ""} ${node?.description || ""}`,
);
if (isRelationshipExplanationUnknown(node, graph)) {
return {
atomicity: "composite",
reason:
"This unknown asks for a broad explanation across multiple observations, so it should be decomposed before asking a direct question.",
decompositionKind: "relationship_explanation",
};
}
if (
isFocusedAtomicUnknownText(nodeText) &&
!isBroadCompositeUnknownText(nodeText)
) {
return {
atomicity: "atomic",
reason:
"This unknown already targets a single concrete detail that can be investigated directly.",
decompositionKind: null,
};
}
if (isBroadCompositeUnknownText(nodeText)) {
return {
atomicity: "composite",
reason:
"This unknown combines multiple broad candidate explanations, so it should be split into smaller dimensions first.",
decompositionKind: "broad_explanation",
};
}
return {
atomicity: "atomic",
reason:
"No deterministic composite pattern was detected, so the unknown can be investigated directly.",
decompositionKind: null,
};
}
export function formulateTieResolutionQuestion({ graph }) {
const comparability = assessComparability(graph);
if (comparability.comparabilityStatus === "uncertain") {
+91 -6
View File
@@ -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);
});
});
+118
View File
@@ -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");
},
);
});
+7 -2
View File
@@ -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/,
+58
View File
@@ -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",
+56 -13
View File
@@ -80,7 +80,7 @@ function makeUpdateSuccess(overrides = {}) {
updatedSituationGraph: {
centralStatement: "Complaints increased while production increased.",
currentSummary: "Updated summary",
activeUnknownNodeId: "n-next-unknown",
activeUnknownNodeId: "n-child-1",
resolvedNodeIds: ["n-unknown"],
nodes: [
{
@@ -125,6 +125,18 @@ function makeUpdateSuccess(overrides = {}) {
value: null,
unit: null,
},
{
id: "n-child-1",
label: "Timing or measurement basis",
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.",
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
parentId: "n-next-unknown",
},
],
edges: [
{
@@ -136,6 +148,15 @@ function makeUpdateSuccess(overrides = {}) {
description:
"This unresolved explanation arises from the now-assessed relationship between the observations.",
},
{
id: "e-child-next",
fromNodeId: "n-child-1",
toNodeId: "n-next-unknown",
relationship: "depends_on",
confidence: "medium",
description:
"This child unknown must be investigated before the broader parent explanation can be resolved.",
},
],
},
proposal: {
@@ -157,6 +178,22 @@ function makeUpdateSuccess(overrides = {}) {
parentId: "n-conclusion",
childIds: [],
},
{
id: "n-child-1",
label: "Timing or measurement basis",
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.",
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: "n-next-unknown",
childIds: [],
},
],
updatedNodes: [
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
@@ -166,28 +203,34 @@ function makeUpdateSuccess(overrides = {}) {
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: ["n-conclusion"],
selectedQuestion: {
nodeId: "n-next-unknown",
nodeId: "n-child-1",
question:
"What changed during that period that could help explain why revenue increased by 18%, but cash in the bank fell over the same period?",
"What evidence would clarify timing or measurement basis?",
reason:
"Formulated as a neutral clarification question because no narrower investigation strategy clearly applied.",
"Formulated from graph context using the evidence_gathering investigation strategy.",
},
},
selectedQuestion: {
nodeId: "n-next-unknown",
nodeId: "n-child-1",
question:
"What changed during that period that could help explain why revenue increased by 18%, but cash in the bank fell over the same period?",
"What evidence would clarify timing or measurement basis?",
reason:
"Formulated as a neutral clarification question because no narrower investigation strategy clearly applied.",
"Formulated from graph context using the evidence_gathering investigation strategy.",
},
affectedNodeIds: ["n-conclusion"],
resolvedUnknownNodeIds: ["n-unknown"],
previousActiveUnknownNodeId: "n-unknown",
newActiveUnknownNodeId: "n-next-unknown",
newActiveUnknownNodeId: "n-child-1",
emergentReasoningNodeCreated: true,
emergentReasoningNodeId: "n-next-unknown",
emergentReasoningNodeReason:
"Created a new unresolved reasoning unknown so the next justified question is backed by the graph.",
atomicityAssessment: "composite",
decompositionPerformed: true,
childUnknownCount: 1,
childNodeIds: ["n-child-1"],
atomicityReason:
"Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question.",
previousReasoningState: {
comparabilityStatus: "uncertain",
reasoningStages: [
@@ -456,7 +499,7 @@ describe("graph-backed UI rendering", () => {
);
expect(html).toContain(
"What changed during that period that could help explain why revenue increased by 18%, but cash in the bank fell over the same period?",
"What evidence would clarify timing or measurement basis?",
);
});
@@ -514,7 +557,7 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("New active unknown");
expect(html).toContain("Next question");
expect(html).toContain(
"What changed during that period that could help explain why revenue increased by 18%, but cash in the bank fell over the same period?",
"What evidence would clarify timing or measurement basis?",
);
});
@@ -526,7 +569,7 @@ describe("graph-backed UI rendering", () => {
selectedQuestion: {
nodeId: "n-next-unknown",
question:
"What changed during that period that could help explain why revenue increased by 18%, but cash in the bank fell over the same period?",
"What evidence would clarify timing or measurement basis?",
reason: "A broad follow-up is now justified.",
},
}),
@@ -543,7 +586,7 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("comparability: confirmed");
expect(html).toContain("relationship: insufficient_information");
expect(html).toContain(
"What changed during that period that could help explain why revenue increased by 18%, but cash in the bank fell over the same period?",
"What evidence would clarify timing or measurement basis?",
);
expect(html).not.toContain("reasoning:comparability");
});
@@ -570,7 +613,7 @@ describe("graph-backed UI rendering", () => {
);
expect(html).toContain(
"What changed during that period that could help explain why revenue increased by 18%, but cash in the bank fell over the same period?",
"What evidence would clarify timing or measurement basis?",
);
});