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,