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", 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: `Change mainly affecting ${firstFocus}`, description: `Need to know whether a 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}.`, }, 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: "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 (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 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; 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, 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, }; }