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
+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") {