feat: decompose composite unknowns before questioning
This commit is contained in:
+250
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user