feat: propagate child resolution through reasoning graph

This commit is contained in:
2026-08-03 06:52:52 +01:00
parent d52690cf2b
commit 49765e95a0
4 changed files with 829 additions and 8 deletions
+348 -7
View File
@@ -433,6 +433,300 @@ function buildChangesApplied(proposal, affectedNodeIds) {
};
}
function appendUniqueValue(values = [], nextValue) {
return nextValue && !values.includes(nextValue)
? [...values, nextValue]
: values;
}
function upsertProposalNodeUpdate(proposalSnapshot, update) {
const existing = proposalSnapshot.updatedNodes.find(
(candidate) => candidate.nodeId === update.nodeId,
);
if (existing) {
if (update.newStatus != null) existing.newStatus = update.newStatus;
if (update.newValue !== undefined) existing.newValue = update.newValue;
if (existing.previousStatus == null) {
existing.previousStatus = update.previousStatus ?? null;
}
if (existing.previousValue === undefined) {
existing.previousValue = update.previousValue ?? null;
}
existing.reason = update.reason;
return existing;
}
proposalSnapshot.updatedNodes.push(update);
return update;
}
function ensureResolvedUnknownId(proposalSnapshot, nodeId) {
if (!proposalSnapshot.resolvedUnknownNodeIds.includes(nodeId)) {
proposalSnapshot.resolvedUnknownNodeIds.push(nodeId);
}
}
function buildPropagationEvidenceId(nodeId) {
return `answer:${nodeId}`;
}
function findDirectChildUnknowns(graph, parentNodeId) {
const parentNode = (graph.nodes || []).find(
(node) => node.id === parentNodeId,
);
const childIds = new Set(parentNode?.childIds || []);
for (const edge of graph.edges || []) {
if (edge.toNodeId === parentNodeId && edge.relationship === "depends_on") {
childIds.add(edge.fromNodeId);
}
}
return (graph.nodes || []).filter(
(node) =>
node.kind === "unknown" &&
(node.parentId === parentNodeId || childIds.has(node.id)),
);
}
function hasExistingDecompositionChildren(graph, parentNodeId) {
return findDirectChildUnknowns(graph, parentNodeId).length > 0;
}
function buildAncestorChain(graph, node) {
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
const chain = [];
const queue = [node?.parentId ?? null].filter(Boolean);
const seen = new Set();
while (queue.length > 0) {
const currentParentId = queue.shift();
if (!currentParentId || seen.has(currentParentId)) continue;
seen.add(currentParentId);
const parentNode = nodesById.get(currentParentId);
if (!parentNode) continue;
chain.push(parentNode);
if (parentNode.parentId) {
queue.push(parentNode.parentId);
}
for (const candidate of graph.nodes || []) {
if (
candidate.id !== parentNode.id &&
(candidate.childIds || []).includes(parentNode.id)
) {
queue.push(candidate.id);
}
}
for (const edge of graph.edges || []) {
if (
edge.fromNodeId !== parentNode.id &&
edge.toNodeId === parentNode.id &&
edge.relationship === "depends_on"
) {
queue.push(edge.fromNodeId);
}
}
}
return chain;
}
function syncParentChildReferences(graph) {
const nodesById = new Map((graph.nodes || []).map((node) => [node.id, node]));
for (const node of graph.nodes || []) {
if (!node.parentId) continue;
const parentNode = nodesById.get(node.parentId);
if (!parentNode) continue;
parentNode.childIds = appendUniqueValue(parentNode.childIds || [], node.id);
parentNode.dependsOn = appendUniqueValue(
parentNode.dependsOn || [],
node.id,
);
}
return graph;
}
function computeParentProgressState(graph, parentNode) {
const childUnknowns = findDirectChildUnknowns(graph, parentNode.id);
const resolvedChildren = childUnknowns.filter(
(child) => child.status === "resolved",
);
const progressedChildren = childUnknowns.filter((child) =>
["resolved", "provisional"].includes(child.status),
);
const totalChildren = childUnknowns.length;
if (totalChildren === 0) {
return {
totalChildren,
resolvedChildren,
progressedChildren,
nextStatus: parentNode.status,
nextConfidence: parentNode.confidence,
parentResolved: parentNode.status === "resolved",
reason: "Parent has no child unknowns to aggregate.",
};
}
if (resolvedChildren.length === totalChildren) {
return {
totalChildren,
resolvedChildren,
progressedChildren,
nextStatus: "resolved",
nextConfidence: "high",
parentResolved: true,
reason:
"All direct child unknowns are resolved, so the parent can now resolve deterministically.",
};
}
if (progressedChildren.length > 0) {
return {
totalChildren,
resolvedChildren,
progressedChildren,
nextStatus: "provisional",
nextConfidence: "high",
parentResolved: false,
reason:
"At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.",
};
}
return {
totalChildren,
resolvedChildren,
progressedChildren,
nextStatus: parentNode.status,
nextConfidence: parentNode.confidence,
parentResolved: parentNode.status === "resolved",
reason: "No direct child progress exists yet for the parent.",
};
}
export function propagateResolvedChildEvidence({
updatedSituationGraph,
proposalSnapshot,
}) {
const resolvedChildNodes = (updatedSituationGraph.nodes || []).filter(
(node) =>
node.kind === "unknown" &&
node.parentId &&
proposalSnapshot.resolvedUnknownNodeIds.includes(node.id),
);
if (resolvedChildNodes.length === 0) {
return {
graph: updatedSituationGraph,
proposalSnapshot,
propagationPerformed: false,
resolvedChildNodeId: null,
parentNodeId: null,
parentStatusBefore: null,
parentStatusAfter: null,
parentConfidenceBefore: null,
parentConfidenceAfter: null,
affectedAncestorIds: [],
nextSelectedSibling: null,
parentResolved: false,
reason: "No resolved decomposition child required upward propagation.",
};
}
const graph = cloneJsonSafe(updatedSituationGraph);
syncParentChildReferences(graph);
const propagationEvents = [];
const affectedAncestorIds = new Set();
for (const resolvedChildNode of resolvedChildNodes) {
const liveChildNode = graph.nodes.find(
(node) => node.id === resolvedChildNode.id,
);
if (!liveChildNode) continue;
liveChildNode.evidenceIds = appendUniqueValue(
liveChildNode.evidenceIds || [],
buildPropagationEvidenceId(liveChildNode.id),
);
const ancestorChain = buildAncestorChain(graph, liveChildNode);
for (const ancestorNode of ancestorChain) {
const beforeStatus = ancestorNode.status;
const beforeConfidence = ancestorNode.confidence;
const progressState = computeParentProgressState(graph, ancestorNode);
ancestorNode.status = progressState.nextStatus;
ancestorNode.confidence = progressState.nextConfidence;
if (progressState.parentResolved) {
ensureResolvedUnknownId(proposalSnapshot, ancestorNode.id);
}
upsertProposalNodeUpdate(proposalSnapshot, {
nodeId: ancestorNode.id,
previousStatus: beforeStatus,
newStatus: progressState.nextStatus,
previousValue: ancestorNode.value ?? null,
newValue: ancestorNode.value ?? null,
reason: progressState.reason,
});
affectedAncestorIds.add(ancestorNode.id);
propagationEvents.push({
resolvedChildNodeId: liveChildNode.id,
parentNodeId: ancestorNode.id,
parentStatusBefore: beforeStatus,
parentStatusAfter: progressState.nextStatus,
parentConfidenceBefore: beforeConfidence,
parentConfidenceAfter: progressState.nextConfidence,
parentResolved: progressState.parentResolved,
reason: progressState.reason,
});
}
}
graph.resolvedNodeIds = [
...new Set([
...graph.resolvedNodeIds,
...proposalSnapshot.resolvedUnknownNodeIds,
]),
];
const siblingSelection = selectActiveUnknownCandidate(
graph,
graph.resolvedNodeIds,
);
const firstEvent = propagationEvents[0] ?? null;
return {
graph,
proposalSnapshot,
propagationPerformed: propagationEvents.length > 0,
resolvedChildNodeId: firstEvent?.resolvedChildNodeId ?? null,
parentNodeId: firstEvent?.parentNodeId ?? null,
parentStatusBefore: firstEvent?.parentStatusBefore ?? null,
parentStatusAfter: firstEvent?.parentStatusAfter ?? null,
parentConfidenceBefore: firstEvent?.parentConfidenceBefore ?? null,
parentConfidenceAfter: firstEvent?.parentConfidenceAfter ?? null,
affectedAncestorIds: [...affectedAncestorIds],
nextSelectedSibling:
siblingSelection?.status === "selected" ? siblingSelection.nodeId : null,
parentResolved: firstEvent?.parentResolved ?? false,
reason:
firstEvent?.reason ??
"Resolved child evidence propagated upward through the decomposition chain.",
};
}
function buildEmergentReasoningUnknownLabel(graph) {
const central = String(graph?.centralStatement || "these observations")
.trim()
@@ -822,17 +1116,17 @@ function buildDecompositionTemplates(parentNode, graph, depth = 0) {
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: `Possible change mainly affecting ${firstFocus}`,
description: `Need to know whether a possible 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}.`,
label: `Possible change mainly affecting ${secondFocus}`,
description: `Need to know whether a possible 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: "Possible one-off event during the period",
description: `Need to know whether a possible one-off event happened during the period, because that could help explain ${context.centralStatement}.`,
}
: {
label: "Mix shift during the period",
@@ -1006,6 +1300,12 @@ function runDeterministicDecomposition({
break;
}
if (hasExistingDecompositionChildren(workingGraph, selectedNode.id)) {
decompositionStoppedReason =
"Selected composite parent already has decomposition children, so they should be reused instead of regenerated.";
break;
}
if (decompositionDepth >= MAX_DECOMPOSITION_DEPTH) {
decompositionStoppedReason =
"Maximum decomposition depth reached before finding a smaller atomic child.";
@@ -1423,6 +1723,22 @@ export function applyValidatedProposal({
nextReasoningState = decompositionResult.reasoningState;
deterministicSelection = decompositionResult.deterministicSelection;
const propagationResult = propagateResolvedChildEvidence({
updatedSituationGraph,
proposalSnapshot: decompositionResult.proposalSnapshot,
});
updatedSituationGraph = propagationResult.graph;
nextReasoningState = buildReasoningState(
updatedSituationGraph,
reasoningResolution.reasoningStateOverride,
);
updatedSituationGraph.reasoningState = nextReasoningState;
deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
const atomicityAssessment = decompositionResult.atomicityAssessment;
const decompositionDepth = decompositionResult.decompositionDepth;
const decompositionAttempted = decompositionResult.decompositionAttempted;
@@ -1444,6 +1760,17 @@ export function applyValidatedProposal({
),
];
const decompositionReason = decompositionStoppedReason;
const propagationPerformed = propagationResult.propagationPerformed;
const resolvedChildNodeId = propagationResult.resolvedChildNodeId;
const parentNodeId = propagationResult.parentNodeId;
const parentStatusBefore = propagationResult.parentStatusBefore;
const parentStatusAfter = propagationResult.parentStatusAfter;
const parentConfidenceBefore = propagationResult.parentConfidenceBefore;
const parentConfidenceAfter = propagationResult.parentConfidenceAfter;
const affectedAncestorIds = propagationResult.affectedAncestorIds;
const nextSelectedSibling = propagationResult.nextSelectedSibling;
const parentResolved = propagationResult.parentResolved;
const propagationReason = propagationResult.reason;
if (
deterministicSelection?.status === "selected" &&
@@ -1560,10 +1887,24 @@ export function applyValidatedProposal({
rejectedChildren,
selectedChildNodeId,
childQualitySummary,
propagationPerformed,
resolvedChildNodeId,
parentNodeId,
parentStatusBefore,
parentStatusAfter,
parentConfidenceBefore,
parentConfidenceAfter,
affectedAncestorIds,
nextSelectedSibling,
parentResolved,
decompositionPerformed,
childUnknownCount: decompositionChildNodeIds.length,
childNodeIds: decompositionChildNodeIds,
atomicityReason: decompositionReason || atomicityAssessment?.reason || null,
atomicityReason:
propagationReason ||
decompositionReason ||
atomicityAssessment?.reason ||
null,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion: finalSelectedQuestion,
+50
View File
@@ -103,6 +103,16 @@ function buildUpdateDiagnostics({
rejectedChildren,
selectedChildNodeId,
childQualitySummary,
propagationPerformed,
resolvedChildNodeId,
parentNodeId,
parentStatusBefore,
parentStatusAfter,
parentConfidenceBefore,
parentConfidenceAfter,
affectedAncestorIds,
nextSelectedSibling,
parentResolved,
decompositionPerformed,
childUnknownCount,
childNodeIds,
@@ -146,6 +156,16 @@ function buildUpdateDiagnostics({
rejectedChildren: rejectedChildren ?? [],
selectedChildNodeId: selectedChildNodeId ?? null,
childQualitySummary: childQualitySummary ?? [],
propagationPerformed: propagationPerformed ?? false,
resolvedChildNodeId: resolvedChildNodeId ?? null,
parentNodeId: parentNodeId ?? null,
parentStatusBefore: parentStatusBefore ?? null,
parentStatusAfter: parentStatusAfter ?? null,
parentConfidenceBefore: parentConfidenceBefore ?? null,
parentConfidenceAfter: parentConfidenceAfter ?? null,
affectedAncestorIds: affectedAncestorIds ?? [],
nextSelectedSibling: nextSelectedSibling ?? null,
parentResolved: parentResolved ?? false,
decompositionPerformed: decompositionPerformed ?? false,
childUnknownCount: childUnknownCount ?? 0,
childNodeIds: childNodeIds ?? [],
@@ -413,6 +433,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
rejectedChildren: [],
selectedChildNodeId: null,
childQualitySummary: [],
propagationPerformed: false,
resolvedChildNodeId: null,
parentNodeId: null,
parentStatusBefore: null,
parentStatusAfter: null,
parentConfidenceBefore: null,
parentConfidenceAfter: null,
affectedAncestorIds: [],
nextSelectedSibling: null,
parentResolved: false,
decompositionPerformed: false,
childUnknownCount: 0,
childNodeIds: [],
@@ -471,6 +501,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
rejectedChildren: applicationResult.rejectedChildren,
selectedChildNodeId: applicationResult.selectedChildNodeId,
childQualitySummary: applicationResult.childQualitySummary,
propagationPerformed: applicationResult.propagationPerformed,
resolvedChildNodeId: applicationResult.resolvedChildNodeId,
parentNodeId: applicationResult.parentNodeId,
parentStatusBefore: applicationResult.parentStatusBefore,
parentStatusAfter: applicationResult.parentStatusAfter,
parentConfidenceBefore: applicationResult.parentConfidenceBefore,
parentConfidenceAfter: applicationResult.parentConfidenceAfter,
affectedAncestorIds: applicationResult.affectedAncestorIds,
nextSelectedSibling: applicationResult.nextSelectedSibling,
parentResolved: applicationResult.parentResolved,
decompositionPerformed: applicationResult.decompositionPerformed,
childUnknownCount: applicationResult.childUnknownCount,
childNodeIds: applicationResult.childNodeIds,
@@ -513,6 +553,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
rejectedChildren: [],
selectedChildNodeId: null,
childQualitySummary: [],
propagationPerformed: false,
resolvedChildNodeId: null,
parentNodeId: null,
parentStatusBefore: null,
parentStatusAfter: null,
parentConfidenceBefore: null,
parentConfidenceAfter: null,
affectedAncestorIds: [],
nextSelectedSibling: null,
parentResolved: false,
decompositionPerformed: false,
childUnknownCount: 0,
childNodeIds: [],