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 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", }; } 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 ( 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, ); 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 ) { 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, decompositionPerformed, childUnknownCount: decompositionChildNodeIds.length, childNodeIds: decompositionChildNodeIds, atomicityReason: decompositionReason || atomicityAssessment?.reason || null, previousActiveUnknownNodeId, newActiveUnknownNodeId, selectedQuestion: finalSelectedQuestion, changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds), graphReferenceValidation: resultReferenceValidation, previousReasoningState: reasoningResolution.previousReasoningState, reasoningState: nextReasoningState, }; }