feat: propagate child resolution through reasoning graph
This commit is contained in:
+348
-7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user