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
+475 -112
View File
@@ -602,6 +602,145 @@ function buildDecompositionContext(graph) {
centralStatement: stripTrailingPunctuation(graph.centralStatement),
firstConcept: firstConcept || "the first signal",
secondConcept: secondConcept || "the second signal",
firstObservationLabel: stripTrailingPunctuation(
firstObservation?.label || "",
),
secondObservationLabel: stripTrailingPunctuation(
secondObservation?.label || "",
),
};
}
export const MAX_DECOMPOSITION_DEPTH = 2;
function splitSemanticTokens(value) {
return normaliseText(value)
.split(" ")
.filter((token) => token.length > 2);
}
function buildSemanticSignature(node) {
return normaliseText(`${node?.label || ""} ${node?.description || ""}`);
}
function calculateTokenOverlapRatio(aTokens, bTokens) {
const a = new Set(aTokens);
const b = new Set(bTokens);
const intersection = [...a].filter((token) => b.has(token)).length;
const largest = Math.max(a.size, b.size, 1);
return intersection / largest;
}
function detectCompoundSignals(text) {
const signals = [];
if (/\b(and|or)\b/.test(text)) {
if (
/\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b[^.]{0,30}\b(and|or)\b[^.]{0,30}\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b/.test(
text,
)
) {
signals.push("conjoined_distinct_concepts");
}
}
if (/\b[a-z]+\s*\/\s*[a-z]+\b/.test(text)) {
signals.push("slash_separated_categories");
}
if (/,[^,]{0,20},/.test(text) || /,\s*[^,]+\s+or\s+[^,]+/.test(text)) {
signals.push("comma_separated_category_list");
}
if (/\btiming or measurement basis\b/.test(text)) {
signals.push("timing_or_measurement_basis");
}
return [...new Set(signals)];
}
function isDirectlyAnswerableChildText(text) {
return !/\b(explanation for why|possible causes|possible reasons|what changed|difference between|divergence|moved differently|factors behind|factors affecting)\b/.test(
text,
);
}
function buildRejectedChildRecord(childNode, reasons, compoundSignals) {
return {
nodeId: childNode.id,
label: childNode.label,
reasons,
compoundSignals,
};
}
function mergeUniqueRecords(existing = [], incoming = [], key = "nodeId") {
const merged = new Map((existing || []).map((item) => [item[key], item]));
for (const item of incoming || []) {
merged.set(item[key], item);
}
return [...merged.values()];
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node));
}
export function assessChildUnknownQuality({
parentNode,
childNode,
siblingNodes,
graph,
}) {
const parentSignature = buildSemanticSignature(parentNode);
const childSignature = buildSemanticSignature(childNode);
const parentTokens = splitSemanticTokens(parentSignature);
const childTokens = splitSemanticTokens(childSignature);
const compoundSignals = detectCompoundSignals(childSignature);
const duplicateSiblingIds = (siblingNodes || [])
.filter((sibling) => sibling.id !== childNode.id)
.filter((sibling) => buildSemanticSignature(sibling) === childSignature)
.map((sibling) => sibling.id);
const reasons = [];
const narrowerThanParent =
childSignature !== parentSignature &&
(childTokens.length < parentTokens.length ||
calculateTokenOverlapRatio(parentTokens, childTokens) < 0.8);
const directlyAnswerable =
isDirectlyAnswerableChildText(childSignature) &&
compoundSignals.length === 0;
const independent =
duplicateSiblingIds.length === 0 && compoundSignals.length === 0;
const atomic = narrowerThanParent && directlyAnswerable && independent;
if (!narrowerThanParent) {
reasons.push("not_narrower_than_parent");
}
if (!directlyAnswerable) {
reasons.push("not_directly_answerable");
}
if (compoundSignals.length > 0) {
reasons.push("compound_child");
}
if (duplicateSiblingIds.length > 0) {
reasons.push("duplicate_sibling");
}
if ((graph?.resolvedNodeIds || []).includes(childNode.id)) {
reasons.push("already_resolved");
}
return {
valid: reasons.length === 0,
atomic,
directlyAnswerable,
independent,
narrowerThanParent,
compoundSignals,
duplicateSiblingIds,
reasons,
};
}
@@ -642,62 +781,127 @@ function findEquivalentDecompositionChild(
});
}
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}.`,
},
];
function describeObservationFocus(context, which) {
const concept =
which === "first" ? context.firstConcept : context.secondConcept;
const label =
which === "first"
? context.firstObservationLabel
: context.secondObservationLabel;
if (concept && !concept.startsWith("the ")) return concept;
if (label) return label.toLowerCase();
return which === "first" ? "the first observation" : "the second observation";
}
function buildDecompositionTemplates(parentNode, graph, depth = 0) {
const context = buildDecompositionContext(graph);
const firstFocus = describeObservationFocus(context, "first");
const secondFocus = describeObservationFocus(context, "second");
if (/\btiming or measurement basis\b/i.test(parentNode.label)) {
return [
{
label: "Whether the two observations reflect different timing",
description: `Need to know whether the two observations reflect different timing, because that would help resolve ${context.centralStatement}.`,
},
{
label: "How the two observations were measured",
description: `Need evidence about the measure used for each observation, because that would help resolve ${context.centralStatement}.`,
},
];
}
return [
{
label: "Whether the two observations reflect different timing",
description: `Need to know whether the two observations reflect different timing, because that could help explain ${context.centralStatement}.`,
},
{
label: "How the two observations were measured",
description: `Need evidence about the measure used for each observation, because that could help explain ${context.centralStatement}.`,
},
{
label: `Change mainly affecting ${firstFocus}`,
description: `Need to know whether a change mainly affected ${firstFocus}, because that could help explain ${context.centralStatement}.`,
},
{
label: `Change mainly affecting ${secondFocus}`,
description: `Need to know whether a change mainly affected ${secondFocus}, because that could help explain ${context.centralStatement}.`,
},
depth === 0
? {
label: "One-off event during the period",
description: `Need to know whether a one-off event happened during the period, because that could help explain ${context.centralStatement}.`,
}
: {
label: "Mix shift during the period",
description: `Need to know whether the mix of cases, customers, or items shifted during the period, because that could help explain ${context.centralStatement}.`,
},
];
}
function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
const templates = buildDecompositionTemplates(parentNode, graph, depth);
const candidateNodes = templates.map(
(template) =>
findEquivalentDecompositionChild(
graph,
parentNode.id,
template.label,
template.description,
) || {
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: [],
},
);
const childNodes = [];
const childEdges = [];
const childNodeIds = [];
let createdCount = 0;
const rejectedChildren = [];
const childQualitySummary = [];
for (const template of templates) {
const existingNode = findEquivalentDecompositionChild(
for (const childNode of candidateNodes) {
const quality = assessChildUnknownQuality({
parentNode,
childNode,
siblingNodes: candidateNodes,
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: [],
};
});
childQualitySummary.push({
nodeId: childNode.id,
label: childNode.label,
valid: quality.valid,
atomic: quality.atomic,
reasons: quality.reasons,
});
if (!quality.valid) {
rejectedChildren.push(
buildRejectedChildRecord(
childNode,
quality.reasons,
quality.compoundSignals,
),
);
continue;
}
childNodeIds.push(childNode.id);
if (existingNode) {
if ((graph.nodes || []).some((node) => node.id === childNode.id)) {
continue;
}
@@ -711,21 +915,197 @@ function buildCompositeUnknownChildren(parentNode, graph) {
description:
"This child unknown must be investigated before the broader parent explanation can be resolved.",
});
createdCount += 1;
}
const acceptedChildCount = childNodeIds.length;
const proposedChildCount = candidateNodes.length;
if (acceptedChildCount < 2) {
return {
accepted: false,
childNodes: [],
childEdges: [],
childNodeIds: [],
proposedChildCount,
acceptedChildCount,
rejectedChildren,
childQualitySummary,
reason:
acceptedChildCount === 0
? "Decomposition stopped because all proposed children failed quality checks."
: "Decomposition stopped because fewer than two valid child unknowns remained after quality checks.",
};
}
return {
accepted: true,
childNodes,
childEdges,
childNodeIds,
createdCount,
proposedChildCount,
acceptedChildCount,
rejectedChildren,
childQualitySummary,
reason:
createdCount > 0
childNodes.length > 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 findNodeById(graph, nodeId) {
return (graph.nodes || []).find((node) => node.id === nodeId) || null;
}
function runDeterministicDecomposition({
graphSnapshot,
proposalSnapshot,
updatedSituationGraph,
reasoningResolution,
deterministicSelection,
}) {
let workingGraph = updatedSituationGraph;
let workingSelection = deterministicSelection;
let workingProposal = proposalSnapshot;
let nextReasoningState = workingGraph.reasoningState;
let lastAtomicityAssessment = null;
let rootAtomicityAssessment = null;
let decompositionDepth = 0;
let decompositionAttempted = false;
let decompositionAccepted = false;
let proposedChildCount = 0;
let acceptedChildCount = 0;
let selectedChildNodeId = null;
let decompositionStoppedReason = null;
let rejectedChildren = [];
let childQualitySummary = [];
while (workingSelection?.status === "selected" && workingSelection?.nodeId) {
const selectedNode = findNodeById(workingGraph, workingSelection.nodeId);
if (!selectedNode) {
decompositionStoppedReason =
"Selected node was not present in the updated graph.";
break;
}
const atomicityAssessment = assessUnknownAtomicity({
node: selectedNode,
graph: workingGraph,
});
lastAtomicityAssessment = atomicityAssessment;
if (!rootAtomicityAssessment) {
rootAtomicityAssessment = atomicityAssessment;
}
if (atomicityAssessment.atomicity === "atomic") {
selectedChildNodeId = decompositionDepth > 0 ? selectedNode.id : null;
decompositionStoppedReason =
decompositionDepth > 0
? "Selected child is atomic and directly answerable."
: "Selected unknown is already atomic.";
break;
}
if (decompositionDepth >= MAX_DECOMPOSITION_DEPTH) {
decompositionStoppedReason =
"Maximum decomposition depth reached before finding a smaller atomic child.";
break;
}
decompositionAttempted = true;
const decomposition = buildCompositeUnknownChildren(
selectedNode,
workingGraph,
decompositionDepth,
);
proposedChildCount = decomposition.proposedChildCount;
acceptedChildCount = decomposition.acceptedChildCount;
rejectedChildren = mergeUniqueRecords(
rejectedChildren,
decomposition.rejectedChildren,
);
childQualitySummary = mergeUniqueRecords(
childQualitySummary,
decomposition.childQualitySummary,
);
if (!decomposition.accepted) {
decompositionStoppedReason = decomposition.reason;
break;
}
const previousGraph = cloneJsonSafe(workingGraph);
const previousProposal = cloneJsonSafe(workingProposal);
const previousReasoningState = cloneJsonSafe(nextReasoningState);
workingProposal.addedNodes.push(...decomposition.childNodes.map(cloneNode));
workingProposal.addedEdges.push(...decomposition.childEdges.map(cloneNode));
const applied = applyGraphUpdate(graphSnapshot, workingProposal);
if (!applied.success) {
return {
success: false,
stage: "application",
errors: applied.errors,
};
}
workingGraph = {
...graphSnapshot,
nodes: applied.nodes,
edges: applied.edges,
resolvedNodeIds: applied.resolvedNodeIds,
};
nextReasoningState = buildReasoningState(
workingGraph,
reasoningResolution.reasoningStateOverride,
);
workingGraph.reasoningState = nextReasoningState;
workingSelection = selectActiveUnknownCandidate(
workingGraph,
workingGraph.resolvedNodeIds,
);
if (workingSelection?.status !== "selected") {
workingGraph = previousGraph;
workingProposal = previousProposal;
nextReasoningState = previousReasoningState;
workingSelection = selectActiveUnknownCandidate(
workingGraph,
workingGraph.resolvedNodeIds,
);
decompositionStoppedReason =
workingSelection?.status === "ambiguous"
? "Decomposition produced multiple equally valid children with no justified distinction."
: "No unresolved child remained selectable after decomposition.";
break;
}
decompositionAccepted = true;
decompositionDepth += 1;
}
return {
success: true,
updatedSituationGraph: workingGraph,
proposalSnapshot: workingProposal,
reasoningState: nextReasoningState,
deterministicSelection: workingSelection,
atomicityAssessment:
rootAtomicityAssessment ?? lastAtomicityAssessment ?? null,
decompositionDepth,
decompositionAttempted,
decompositionAccepted,
decompositionStoppedReason,
proposedChildCount,
acceptedChildCount,
rejectedChildren,
childQualitySummary,
selectedChildNodeId,
};
}
function isComparabilityQuestion(question) {
const text = String(question || "").toLowerCase();
return (
@@ -1027,71 +1407,44 @@ export function applyValidatedProposal({
updatedSituationGraph.resolvedNodeIds,
);
let atomicityAssessment = null;
let decompositionPerformed = false;
let decompositionChildNodeIds = [];
let decompositionReason = null;
const decompositionResult = runDeterministicDecomposition({
graphSnapshot,
proposalSnapshot,
updatedSituationGraph,
reasoningResolution,
deterministicSelection,
});
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 (!decompositionResult.success) {
return decompositionResult;
}
updatedSituationGraph = decompositionResult.updatedSituationGraph;
nextReasoningState = decompositionResult.reasoningState;
deterministicSelection = decompositionResult.deterministicSelection;
const atomicityAssessment = decompositionResult.atomicityAssessment;
const decompositionDepth = decompositionResult.decompositionDepth;
const decompositionAttempted = decompositionResult.decompositionAttempted;
const decompositionAccepted = decompositionResult.decompositionAccepted;
const decompositionStoppedReason =
decompositionResult.decompositionStoppedReason;
const proposedChildCount = decompositionResult.proposedChildCount;
const acceptedChildCount = decompositionResult.acceptedChildCount;
const rejectedChildren = decompositionResult.rejectedChildren;
const childQualitySummary = decompositionResult.childQualitySummary;
const selectedChildNodeId = decompositionResult.selectedChildNodeId;
const decompositionPerformed =
decompositionAttempted && decompositionAccepted;
const decompositionChildNodeIds = [
...new Set(
decompositionResult.proposalSnapshot.addedNodes
.filter((node) => node.kind === "unknown" && node.parentId != null)
.map((node) => node.id),
),
];
const decompositionReason = decompositionStoppedReason;
if (
deterministicSelection?.status === "selected" &&
deterministicSelection?.nodeId
@@ -1197,6 +1550,16 @@ export function applyValidatedProposal({
emergentReasoningNodeId: emergentReasoningUnknown?.node?.id ?? null,
emergentReasoningNodeReason: emergentReasoningUnknown?.reason ?? null,
atomicityAssessment: atomicityAssessment?.atomicity ?? null,
atomicityDecisionReason: atomicityAssessment?.reason ?? null,
decompositionDepth,
decompositionAttempted,
decompositionAccepted,
decompositionStoppedReason,
proposedChildCount,
acceptedChildCount,
rejectedChildren,
selectedChildNodeId,
childQualitySummary,
decompositionPerformed,
childUnknownCount: decompositionChildNodeIds.length,
childNodeIds: decompositionChildNodeIds,