import { describeGraph } from "./builder.js"; import { assessUnknownAtomicity, buildReasoningState, classifyObservationRelationship, COMPARABILITY_REASONING_NODE_ID, formulateQuestion, } from "./question-formulator.js"; import { graphUpdateSchema, makeNodeId, situationGraphSchema, } from "./schema.js"; import { applyGraphUpdate, detectDuplicateNodeIds, findAffectedNodes, scoreUnknownCandidate, selectActiveUnknownCandidate, validateGraphReferences, validateGraphUpdate, } from "./utils.js"; function cloneJsonSafe(value) { return JSON.parse(JSON.stringify(value)); } function zodIssuesToErrors(error) { return ( error?.issues?.map((issue) => { const path = issue.path?.length ? `${issue.path.join(".")}: ` : ""; return `${path}${issue.message}`; }) ?? ["Validation failed"] ); } function collectDuplicateEdgeIds(edges) { const counts = new Map(); for (const edge of edges) { counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1); } return [...counts.entries()] .filter(([, count]) => count > 1) .map(([edgeId, count]) => ({ edgeId, count })); } function normaliseText(value) { return String(value || "") .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .trim(); } function buildNodeById(graph, addedNodes = []) { return new Map( [...graph.nodes, ...addedNodes].map((node) => [node.id, node]), ); } function isCompoundQuestion(question) { if (typeof question !== "string") return false; const trimmed = question.trim(); if (!trimmed) return false; const questionMarks = (trimmed.match(/\?/g) || []).length; if (questionMarks > 1) return true; if (/\?\s*(and|or)\b/i.test(trimmed)) return true; if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed)) return true; return false; } function validateAddedUnknowns(graph, proposal) { const errors = []; const addedUnknowns = proposal.addedNodes.filter( (node) => node.kind === "unknown", ); if (addedUnknowns.length > 3) { errors.push( `Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`, ); } const unresolvedExistingUnknowns = graph.nodes.filter( (node) => node.kind === "unknown" && !proposal.resolvedUnknownNodeIds.includes(node.id), ); const seenAddedUnknownMeanings = new Map(); const answerDerivedNodeIds = new Set([ ...proposal.updatedNodes.map((update) => update.nodeId), ...proposal.resolvedUnknownNodeIds, ...proposal.addedNodes .filter((node) => node.kind !== "unknown") .map((node) => node.id), ]); const proposalNodeById = buildNodeById(graph, proposal.addedNodes); function hasExplicitNodeReference(fromNode, toNodeId) { if (!fromNode || !toNodeId) return false; return ( fromNode.parentId === toNodeId || fromNode.dependsOn.includes(toNodeId) || fromNode.affects.includes(toNodeId) || fromNode.childIds.includes(toNodeId) ); } function hasExplicitAnswerDerivedRelationship(unknownNode) { const connectedEdge = proposal.addedEdges.find( (edge) => (edge.fromNodeId === unknownNode.id && answerDerivedNodeIds.has(edge.toNodeId)) || (edge.toNodeId === unknownNode.id && answerDerivedNodeIds.has(edge.fromNodeId)), ); if (connectedEdge) { return true; } for (const answerDerivedNodeId of answerDerivedNodeIds) { const answerDerivedNode = proposalNodeById.get(answerDerivedNodeId); if ( hasExplicitNodeReference(unknownNode, answerDerivedNodeId) || hasExplicitNodeReference(answerDerivedNode, unknownNode.id) ) { return true; } } return false; } for (const unknownNode of addedUnknowns) { const meaningKeys = [ normaliseText(unknownNode.label), normaliseText(unknownNode.description), ].filter(Boolean); for (const meaningKey of meaningKeys) { if (seenAddedUnknownMeanings.has(meaningKey)) { errors.push( `Proposal adds duplicate unknown meaning: "${unknownNode.label}"`, ); break; } seenAddedUnknownMeanings.set(meaningKey, unknownNode.id); } for (const existingUnknown of unresolvedExistingUnknowns) { const existingMeaningKeys = [ normaliseText(existingUnknown.label), normaliseText(existingUnknown.description), ].filter(Boolean); if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) { errors.push( `Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`, ); break; } } if ( unknownNode.description.trim() === unknownNode.label.trim() || !/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test( unknownNode.description, ) ) { errors.push( `New unknown must include why it matters in its description: "${unknownNode.id}"`, ); } if (!hasExplicitAnswerDerivedRelationship(unknownNode)) { errors.push( `New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`, ); } } return errors; } function validateSelectedQuestion(graph, proposal) { const errors = []; const selectedQuestion = proposal.selectedQuestion; const nodeById = buildNodeById(graph, proposal.addedNodes); if (selectedQuestion == null) { return { errors, selectedQuestionNodeId: null }; } const node = nodeById.get(selectedQuestion.nodeId); if (!node) { errors.push( `selectedQuestion references missing node: "${selectedQuestion.nodeId}"`, ); return { errors, selectedQuestionNodeId: selectedQuestion.nodeId }; } if (node.kind !== "unknown") { errors.push( `selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`, ); } const resolvesNode = proposal.resolvedUnknownNodeIds.includes( selectedQuestion.nodeId, ); const updatedStatus = proposal.updatedNodes.find( (update) => update.nodeId === selectedQuestion.nodeId, )?.newStatus; const effectiveStatus = updatedStatus ?? node.status; if (resolvesNode || effectiveStatus === "resolved") { errors.push( `selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`, ); } if ( graph.activeUnknownNodeId && proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) && selectedQuestion.nodeId === graph.activeUnknownNodeId ) { errors.push( `selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`, ); } if (isCompoundQuestion(selectedQuestion.question)) { errors.push("selectedQuestion must be a single non-compound question"); } const resolvedNodeIds = [ ...(graph.resolvedNodeIds || []), ...(proposal.resolvedUnknownNodeIds || []), ]; const candidateScore = scoreUnknownCandidate( { ...graph, nodes: [...graph.nodes, ...(proposal.addedNodes || [])], edges: [...graph.edges, ...(proposal.addedEdges || [])], }, node, resolvedNodeIds, ); return { errors, selectedQuestionNodeId: selectedQuestion.nodeId }; } function validateQuestionSelectionRequirement(graph, proposal) { const addedConsequentialUnknowns = proposal.addedNodes.filter( (node) => node.kind === "unknown" && node.status !== "resolved", ); if ( proposal.selectedQuestion == null && addedConsequentialUnknowns.length > 0 ) { return [ "selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown", ]; } return []; } function buildResolvedUnknownUpdate(node) { return { nodeId: node.id, previousStatus: node.status ?? null, newStatus: "resolved", previousValue: node.value ?? null, newValue: node.value ?? null, reason: "Resolved because the proposal explicitly marked this unknown as resolved.", }; } function reconcileResolutionSemantics(graph, proposal) { const nextProposal = cloneJsonSafe(proposal); const errors = []; const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node])); const updatedNodeById = new Map( nextProposal.updatedNodes.map((nodeUpdate) => [ nodeUpdate.nodeId, nodeUpdate, ]), ); for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) { const existingNode = graphNodeById.get(resolvedUnknownNodeId); if (!existingNode) { errors.push( `Resolved unknown must reference an existing node: "${resolvedUnknownNodeId}"`, ); continue; } if (existingNode.kind !== "unknown") { errors.push( `Resolved unknown must reference an existing unknown node: "${resolvedUnknownNodeId}"`, ); continue; } const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId); if (!existingUpdate) { const syntheticUpdate = buildResolvedUnknownUpdate(existingNode); nextProposal.updatedNodes.push(syntheticUpdate); updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate); continue; } if (existingUpdate.newStatus !== "resolved") { existingUpdate.newStatus = "resolved"; if (existingUpdate.previousStatus == null) { existingUpdate.previousStatus = existingNode.status ?? null; } if (existingUpdate.previousValue === undefined) { existingUpdate.previousValue = existingNode.value ?? null; } } } for (const update of nextProposal.updatedNodes) { const existingNode = graphNodeById.get(update.nodeId); if ( existingNode?.kind === "unknown" && update.newStatus === "resolved" && !nextProposal.resolvedUnknownNodeIds.includes(update.nodeId) ) { errors.push( `Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`, ); } } return { proposal: nextProposal, errors, }; } function validateSemanticDuplicateUnknowns(graph, proposal) { const errors = []; const unresolvedUnknowns = graph.nodes.filter( (node) => node.kind === "unknown" && !proposal.resolvedUnknownNodeIds.includes(node.id), ); for (const addedNode of proposal.addedNodes) { const addedTexts = [ normaliseText(addedNode.label), normaliseText(addedNode.description), ].filter(Boolean); for (const unresolvedUnknown of unresolvedUnknowns) { const unresolvedTexts = [ normaliseText(unresolvedUnknown.label), normaliseText(unresolvedUnknown.description), ].filter(Boolean); const duplicatesMeaning = addedTexts.some((text) => unresolvedTexts.includes(text), ); if (!duplicatesMeaning) continue; const linkedToUnknown = proposal.addedEdges.some( (edge) => (edge.fromNodeId === addedNode.id && edge.toNodeId === unresolvedUnknown.id) || (edge.toNodeId === addedNode.id && edge.fromNodeId === unresolvedUnknown.id), ); const updatedUnknown = proposal.updatedNodes.some( (update) => update.nodeId === unresolvedUnknown.id, ); if (!linkedToUnknown && !updatedUnknown) { errors.push( `Proposal adds a node duplicating unresolved unknown meaning without linking or resolving it: "${unresolvedUnknown.id}"`, ); } } } return errors; } function buildAffectedNodeIds(graph, proposal) { const affected = new Set(proposal.affectedNodeIds ?? []); for (const update of proposal.updatedNodes ?? []) { affected.add(update.nodeId); for (const nodeId of findAffectedNodes(graph, update.nodeId)) { affected.add(nodeId); } } for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) { affected.add(nodeId); for (const affectedNodeId of findAffectedNodes(graph, nodeId)) { affected.add(affectedNodeId); } } return [...affected]; } function buildChangesApplied(proposal, affectedNodeIds) { return { addedNodeCount: proposal.addedNodes.length, addedUnknownCount: proposal.addedNodes.filter( (node) => node.kind === "unknown", ).length, updatedNodeCount: proposal.updatedNodes.length, addedEdgeCount: proposal.addedEdges.length, removedEdgeCount: proposal.removedEdgeIds.length, resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length, affectedNodeCount: affectedNodeIds.length, }; } function appendUniqueValue(values = [], nextValue) { return nextValue && !values.includes(nextValue) ? [...values, nextValue] : values; } function getNodeConfidenceAssessment(node) { return ( node?.confidenceAssessment || { evidenceConfidence: node?.confidence ?? "medium", completenessStatus: node?.status === "resolved" ? "complete" : node?.status === "provisional" ? "partial" : "empty", conclusionConfidence: node?.status === "resolved" ? (node?.confidence ?? "high") : node?.status === "provisional" ? (node?.confidence ?? "medium") : "low", } ); } function confidenceFromAssessment(assessment) { return assessment?.conclusionConfidence ?? "medium"; } 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); } } } 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 contradictoryChildren = childUnknowns.filter( (child) => child.status === "contradicted", ); const totalChildren = childUnknowns.length; const resolvedCount = resolvedChildren.length; const unresolvedCount = childUnknowns.filter( (child) => child.status !== "resolved", ).length; const beforeAssessment = getNodeConfidenceAssessment(parentNode); if (totalChildren === 0) { const nextAssessment = { evidenceConfidence: beforeAssessment.evidenceConfidence, completenessStatus: beforeAssessment.completenessStatus, conclusionConfidence: beforeAssessment.conclusionConfidence, }; return { totalChildren, resolvedChildren, progressedChildren, contradictoryChildren, nextStatus: parentNode.status, nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: parentNode.status === "resolved", confidenceCapReason: "no_child_structure", reason: "Parent has no child unknowns to aggregate.", }; } let nextAssessment; let confidenceCapReason; if (contradictoryChildren.length > 0) { nextAssessment = { evidenceConfidence: resolvedCount > 0 ? "medium" : "low", completenessStatus: resolvedCount === 0 ? "empty" : "partial", conclusionConfidence: "low", }; confidenceCapReason = "contradictory_direct_children"; } else if (resolvedCount === 0) { nextAssessment = { evidenceConfidence: "low", completenessStatus: "empty", conclusionConfidence: "low", }; confidenceCapReason = "no_resolved_direct_children"; } else if (resolvedCount < totalChildren) { nextAssessment = { evidenceConfidence: "high", completenessStatus: "partial", conclusionConfidence: "medium", }; confidenceCapReason = "unresolved_direct_children_cap_conclusion"; } else { nextAssessment = { evidenceConfidence: "high", completenessStatus: "complete", conclusionConfidence: "high", }; confidenceCapReason = null; } if (resolvedChildren.length === totalChildren) { return { totalChildren, resolvedChildren, progressedChildren, contradictoryChildren, nextStatus: "resolved", nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: true, resolvedDirectChildren: resolvedCount, unresolvedDirectChildren: unresolvedCount, contradictoryDirectChildren: contradictoryChildren.length, confidenceCapReason, reason: "All direct child unknowns are resolved, so the parent can now resolve deterministically.", }; } if (progressedChildren.length > 0) { return { totalChildren, resolvedChildren, progressedChildren, contradictoryChildren, nextStatus: "provisional", nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: false, resolvedDirectChildren: resolvedCount, unresolvedDirectChildren: unresolvedCount, contradictoryDirectChildren: contradictoryChildren.length, confidenceCapReason, 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, contradictoryChildren, nextStatus: parentNode.status, nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: parentNode.status === "resolved", resolvedDirectChildren: resolvedCount, unresolvedDirectChildren: unresolvedCount, contradictoryDirectChildren: contradictoryChildren.length, confidenceCapReason, 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, evidenceConfidenceBefore: null, evidenceConfidenceAfter: null, completenessBefore: null, completenessAfter: null, conclusionConfidenceBefore: null, conclusionConfidenceAfter: null, resolvedDirectChildren: 0, unresolvedDirectChildren: 0, contradictoryDirectChildren: 0, confidenceCapReason: null, ancestorPropagationStoppedReason: "no_resolved_child_propagation_needed", 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(); let ancestorPropagationStoppedReason = "no_ancestor_state_changed"; 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 beforeAssessment = getNodeConfidenceAssessment(ancestorNode); const progressState = computeParentProgressState(graph, ancestorNode); ancestorNode.status = progressState.nextStatus; ancestorNode.confidence = progressState.nextConfidence; ancestorNode.confidenceAssessment = progressState.nextConfidenceAssessment; if ( beforeStatus === progressState.nextStatus && beforeConfidence === progressState.nextConfidence && JSON.stringify(beforeAssessment) === JSON.stringify(progressState.nextConfidenceAssessment) ) { continue; } ancestorPropagationStoppedReason = "ancestor_state_changed"; 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, evidenceConfidenceBefore: beforeAssessment.evidenceConfidence, evidenceConfidenceAfter: progressState.nextConfidenceAssessment.evidenceConfidence, completenessBefore: beforeAssessment.completenessStatus, completenessAfter: progressState.nextConfidenceAssessment.completenessStatus, conclusionConfidenceBefore: beforeAssessment.conclusionConfidence, conclusionConfidenceAfter: progressState.nextConfidenceAssessment.conclusionConfidence, resolvedDirectChildren: progressState.resolvedDirectChildren, unresolvedDirectChildren: progressState.unresolvedDirectChildren, contradictoryDirectChildren: progressState.contradictoryDirectChildren, confidenceCapReason: progressState.confidenceCapReason, 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, evidenceConfidenceBefore: firstEvent?.evidenceConfidenceBefore ?? null, evidenceConfidenceAfter: firstEvent?.evidenceConfidenceAfter ?? null, completenessBefore: firstEvent?.completenessBefore ?? null, completenessAfter: firstEvent?.completenessAfter ?? null, conclusionConfidenceBefore: firstEvent?.conclusionConfidenceBefore ?? null, conclusionConfidenceAfter: firstEvent?.conclusionConfidenceAfter ?? null, resolvedDirectChildren: firstEvent?.resolvedDirectChildren ?? 0, unresolvedDirectChildren: firstEvent?.unresolvedDirectChildren ?? 0, contradictoryDirectChildren: firstEvent?.contradictoryDirectChildren ?? 0, confidenceCapReason: firstEvent?.confidenceCapReason ?? null, ancestorPropagationStoppedReason, 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() .replace(/[.?!:;]+$/g, ""); return `Explanation for why ${central}`; } function findEquivalentEmergentUnknown(graph, label, description) { const targetId = makeNodeId(label); const targetTexts = [normaliseText(label), normaliseText(description)].filter( Boolean, ); return (graph.nodes || []).find((node) => { if ( node.kind !== "unknown" || (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 buildEmergentReasoningUnknown(graph, relationshipAssessment) { if (!relationshipAssessment?.relationshipAssessed) { return null; } if (!relationshipAssessment.questionRequired) { return null; } if ( ![ "potentially_related", "insufficient_information", "contradictory", ].includes(relationshipAssessment.relationshipStatus) ) { return null; } const label = buildEmergentReasoningUnknownLabel(graph); const description = "Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship."; const existingNode = findEquivalentEmergentUnknown(graph, label, description); if (existingNode) { return { created: false, node: existingNode, edges: [], reason: "Reused an existing unresolved reasoning unknown for the next investigation stage.", }; } const observationNodes = (graph.nodes || []).filter( (node) => node.kind === "observation" && node.status === "supported", ); const relationshipNode = (graph.nodes || []).find( (node) => node.kind === "relationship" && node.status === "supported", ); const nodeId = makeNodeId(label); const relatedNodeIds = relationshipNode ? [relationshipNode.id] : observationNodes.slice(0, 2).map((node) => node.id); if (relatedNodeIds.length === 0) { return null; } const node = { id: nodeId, label, description, kind: "unknown", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: relatedNodeIds, affects: [], parentId: relationshipNode?.id ?? null, childIds: [], }; const edges = relatedNodeIds.map((relatedNodeId) => ({ id: `e-${relatedNodeId.slice(0, 6)}-${nodeId.slice(0, 6)}`, fromNodeId: relatedNodeId, toNodeId: nodeId, relationship: relationshipNode?.id === relatedNodeId ? "depends_on" : "other", confidence: "medium", description: "This unresolved explanation arises from the now-assessed relationship between the observations.", })); return { created: true, node, edges, reason: "Created a new unresolved reasoning unknown so the next justified question is backed by the graph.", }; } 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", 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, }; } 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 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: `Possible change mainly affecting ${firstFocus}`, description: `Need to know whether a possible change mainly affected ${firstFocus}, 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: "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", 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 = []; const rejectedChildren = []; const childQualitySummary = []; for (const childNode of candidateNodes) { const quality = assessChildUnknownQuality({ parentNode, childNode, siblingNodes: candidateNodes, graph, }); 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 ((graph.nodes || []).some((node) => node.id === childNode.id)) { 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.", }); } 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, proposedChildCount, acceptedChildCount, rejectedChildren, childQualitySummary, reason: 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 (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."; 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 ( text.includes("same basis") || text.includes("same scale") || text.includes("same period") ); } function answerConfirmsComparability(answer) { const text = String(answer || "").toLowerCase(); return ( /\byes\b/.test(text) && (text.includes("same accounting period") || text.includes("same management accounts") || text.includes("same basis") || text.includes("same scale") || text.includes("both figures cover the same")) ); } function deriveReasoningStateOverride({ graph, previousQuestion, answer, resolvedUnknownNodeIds, }) { const previousReasoningState = buildReasoningState(graph); const previousComparabilityStatus = previousReasoningState.comparabilityStatus ?? null; if ( previousComparabilityStatus === "uncertain" && isComparabilityQuestion(previousQuestion) && answerConfirmsComparability(answer) ) { return { reasoningStateOverride: { comparabilityStatus: "confirmed", comparabilityReason: "Comparability was confirmed by the user answer covering the same period and source basis.", comparabilityEvidence: resolvedUnknownNodeIds, }, resolvedReasoningNodeIds: [COMPARABILITY_REASONING_NODE_ID], previousReasoningState, }; } return { reasoningStateOverride: {}, resolvedReasoningNodeIds: [], previousReasoningState, }; } export function applyValidatedProposal({ situationGraph, proposal, previousQuestion = null, answer = null, }) { const graphValidation = situationGraphSchema.safeParse(situationGraph); const proposalValidation = graphUpdateSchema.safeParse(proposal); const existingGraphReferenceValidation = graphValidation.success ? validateGraphReferences(situationGraph) : null; const existingDuplicateNodeIds = graphValidation.success ? detectDuplicateNodeIds(situationGraph.nodes) : []; const existingDuplicateEdgeIds = graphValidation.success ? collectDuplicateEdgeIds(situationGraph.edges) : []; if ( !graphValidation.success || !existingGraphReferenceValidation?.valid || existingDuplicateNodeIds.length > 0 || existingDuplicateEdgeIds.length > 0 ) { return { success: false, stage: "graph_validation", errors: [ ...(!graphValidation.success ? zodIssuesToErrors(graphValidation.error) : []), ...(!existingGraphReferenceValidation?.valid ? existingGraphReferenceValidation.errors : []), ...existingDuplicateNodeIds.map( ({ nodeId, count }) => `Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`, ), ...existingDuplicateEdgeIds.map( ({ edgeId, count }) => `Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`, ), ], }; } if (!proposalValidation.success) { return { success: false, stage: "proposal_compatibility", errors: zodIssuesToErrors(proposalValidation.error), }; } const reconciledProposal = reconcileResolutionSemantics( situationGraph, proposalValidation.data, ); const validatedProposal = reconciledProposal.proposal; const proposalCompatibilityErrors = []; proposalCompatibilityErrors.push(...reconciledProposal.errors); const proposalGraphValidation = validateGraphUpdate( situationGraph, validatedProposal, ); if (!proposalGraphValidation.valid) { proposalCompatibilityErrors.push(...proposalGraphValidation.errors); } const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id)); const reachableNodeIds = new Set([ ...situationGraph.nodes.map((node) => node.id), ...validatedProposal.addedNodes.map((node) => node.id), ]); const addedEdgeDuplicateIds = collectDuplicateEdgeIds( validatedProposal.addedEdges, ); proposalCompatibilityErrors.push( ...addedEdgeDuplicateIds.map( ({ edgeId, count }) => `Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`, ), ); for (const edge of validatedProposal.addedEdges) { if (existingEdgeIds.has(edge.id)) { proposalCompatibilityErrors.push( `Cannot add edge with duplicate ID: "${edge.id}"`, ); } if (!reachableNodeIds.has(edge.fromNodeId)) { proposalCompatibilityErrors.push( `Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`, ); } if (!reachableNodeIds.has(edge.toNodeId)) { proposalCompatibilityErrors.push( `Added edge references non-existent toNodeId: "${edge.toNodeId}"`, ); } } const removedEdgeIds = new Set(validatedProposal.removedEdgeIds); for (const edgeId of removedEdgeIds) { if (!existingEdgeIds.has(edgeId)) { proposalCompatibilityErrors.push( `Cannot remove non-existent edge: "${edgeId}"`, ); } } const combinedNodeDuplicates = detectDuplicateNodeIds([ ...situationGraph.nodes, ...validatedProposal.addedNodes, ]); proposalCompatibilityErrors.push( ...combinedNodeDuplicates.map( ({ nodeId, count }) => `Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`, ), ); proposalCompatibilityErrors.push( ...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal), ); proposalCompatibilityErrors.push( ...validateAddedUnknowns(situationGraph, validatedProposal), ); const selectedQuestionValidation = validateSelectedQuestion( situationGraph, validatedProposal, ); proposalCompatibilityErrors.push(...selectedQuestionValidation.errors); proposalCompatibilityErrors.push( ...validateQuestionSelectionRequirement(situationGraph, validatedProposal), ); if (proposalCompatibilityErrors.length > 0) { return { success: false, stage: "proposal_compatibility", errors: proposalCompatibilityErrors, }; } const graphSnapshot = cloneJsonSafe(situationGraph); const proposalSnapshot = cloneJsonSafe(validatedProposal); const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null; const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot); const reasoningResolution = deriveReasoningStateOverride({ graph: graphSnapshot, previousQuestion, answer, resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, }); const provisionalApplied = applyGraphUpdate(graphSnapshot, proposalSnapshot); if (!provisionalApplied.success) { return { success: false, stage: "application", errors: provisionalApplied.errors, }; } const provisionalGraph = { ...graphSnapshot, nodes: provisionalApplied.nodes, edges: provisionalApplied.edges, resolvedNodeIds: provisionalApplied.resolvedNodeIds, }; provisionalGraph.reasoningState = buildReasoningState( provisionalGraph, reasoningResolution.reasoningStateOverride, ); const relationshipAssessment = classifyObservationRelationship(provisionalGraph); const emergentReasoningUnknown = buildEmergentReasoningUnknown( provisionalGraph, relationshipAssessment, ); if (emergentReasoningUnknown?.created) { proposalSnapshot.addedNodes.push(emergentReasoningUnknown.node); proposalSnapshot.addedEdges.push(...emergentReasoningUnknown.edges); } let applied = applyGraphUpdate(graphSnapshot, proposalSnapshot); if (!applied.success) { return { success: false, stage: "application", errors: applied.errors, }; } let updatedSituationGraph = { ...graphSnapshot, nodes: applied.nodes, edges: applied.edges, resolvedNodeIds: applied.resolvedNodeIds, }; let nextReasoningState = buildReasoningState( updatedSituationGraph, reasoningResolution.reasoningStateOverride, ); updatedSituationGraph.reasoningState = nextReasoningState; const activeUnknownWasResolved = previousActiveUnknownNodeId != null && updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId); let newActiveUnknownNodeId = previousActiveUnknownNodeId; if (activeUnknownWasResolved) { newActiveUnknownNodeId = null; } if (validatedProposal.selectedQuestion?.nodeId) { newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId; } const remainingUnknownExists = newActiveUnknownNodeId != null && updatedSituationGraph.nodes.some( (node) => node.id === newActiveUnknownNodeId && node.kind === "unknown" && !updatedSituationGraph.resolvedNodeIds.includes(node.id), ); if (!remainingUnknownExists) { newActiveUnknownNodeId = selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds, )?.nodeId ?? null; } let deterministicSelection = selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds, ); const decompositionResult = runDeterministicDecomposition({ graphSnapshot, proposalSnapshot, updatedSituationGraph, reasoningResolution, deterministicSelection, }); if (!decompositionResult.success) { return decompositionResult; } updatedSituationGraph = decompositionResult.updatedSituationGraph; 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; 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; 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 evidenceConfidenceBefore = propagationResult.evidenceConfidenceBefore; const evidenceConfidenceAfter = propagationResult.evidenceConfidenceAfter; const completenessBefore = propagationResult.completenessBefore; const completenessAfter = propagationResult.completenessAfter; const conclusionConfidenceBefore = propagationResult.conclusionConfidenceBefore; const conclusionConfidenceAfter = propagationResult.conclusionConfidenceAfter; const resolvedDirectChildren = propagationResult.resolvedDirectChildren; const unresolvedDirectChildren = propagationResult.unresolvedDirectChildren; const contradictoryDirectChildren = propagationResult.contradictoryDirectChildren; const confidenceCapReason = propagationResult.confidenceCapReason; const ancestorPropagationStoppedReason = propagationResult.ancestorPropagationStoppedReason; const affectedAncestorIds = propagationResult.affectedAncestorIds; const nextSelectedSibling = propagationResult.nextSelectedSibling; const parentResolved = propagationResult.parentResolved; const propagationReason = propagationResult.reason; if ( deterministicSelection?.status === "selected" && deterministicSelection?.nodeId ) { newActiveUnknownNodeId = deterministicSelection.nodeId; } else if (deterministicSelection?.status === "ambiguous") { newActiveUnknownNodeId = null; } updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId; updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); const selectedNode = deterministicSelection?.status === "selected" && deterministicSelection?.nodeId ? updatedSituationGraph.nodes.find( (node) => node.id === deterministicSelection.nodeId, ) : null; const formulatedQuestion = selectedNode ? formulateQuestion({ node: selectedNode, graph: updatedSituationGraph, context: { resolvedValues: validatedProposal.updatedNodes .map((update) => update.newValue) .filter( (value) => typeof value === "string" && value.trim().length > 0, ), selectionState: deterministicSelection, }, }) : null; const finalSelectedQuestion = deterministicSelection?.status === "ambiguous" ? { nodeId: null, tiedCandidateIds: deterministicSelection.tiedCandidateIds, question: null, reason: deterministicSelection.reason, } : deterministicSelection?.status === "selected" ? { nodeId: deterministicSelection.nodeId, question: formulatedQuestion?.question || deterministicSelection.question, reason: formulatedQuestion?.reason || deterministicSelection.reason, strategy: formulatedQuestion?.strategy, investigationStrategy: formulatedQuestion?.investigationStrategy, } : null; const resultGraphValidation = situationGraphSchema.safeParse( updatedSituationGraph, ); const resultReferenceValidation = resultGraphValidation.success ? validateGraphReferences(updatedSituationGraph) : null; const resultDuplicateNodeIds = resultGraphValidation.success ? detectDuplicateNodeIds(updatedSituationGraph.nodes) : []; const resultDuplicateEdgeIds = resultGraphValidation.success ? collectDuplicateEdgeIds(updatedSituationGraph.edges) : []; if ( !resultGraphValidation.success || !resultReferenceValidation?.valid || resultDuplicateNodeIds.length > 0 || resultDuplicateEdgeIds.length > 0 ) { return { success: false, stage: "result_validation", errors: [ ...(!resultGraphValidation.success ? zodIssuesToErrors(resultGraphValidation.error) : []), ...(!resultReferenceValidation?.valid ? resultReferenceValidation.errors : []), ...resultDuplicateNodeIds.map( ({ nodeId, count }) => `Updated graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`, ), ...resultDuplicateEdgeIds.map( ({ edgeId, count }) => `Updated graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`, ), ], }; } return { success: true, updatedSituationGraph, graphUpdate: proposalSnapshot, affectedNodeIds, resolvedUnknownNodeIds: proposalSnapshot.resolvedUnknownNodeIds, resolvedReasoningNodeIds: reasoningResolution.resolvedReasoningNodeIds, emergentReasoningNodeCreated: Boolean(emergentReasoningUnknown?.created), 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, propagationPerformed, resolvedChildNodeId, parentNodeId, parentStatusBefore, parentStatusAfter, parentConfidenceBefore, parentConfidenceAfter, evidenceConfidenceBefore, evidenceConfidenceAfter, completenessBefore, completenessAfter, conclusionConfidenceBefore, conclusionConfidenceAfter, resolvedDirectChildren, unresolvedDirectChildren, contradictoryDirectChildren, confidenceCapReason, ancestorPropagationStoppedReason, affectedAncestorIds, nextSelectedSibling, parentResolved, decompositionPerformed, childUnknownCount: decompositionChildNodeIds.length, childNodeIds: decompositionChildNodeIds, atomicityReason: propagationReason || decompositionReason || atomicityAssessment?.reason || null, previousActiveUnknownNodeId, newActiveUnknownNodeId, selectedQuestion: finalSelectedQuestion, changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds), graphReferenceValidation: resultReferenceValidation, previousReasoningState: reasoningResolution.previousReasoningState, reasoningState: nextReasoningState, }; }