import { describeGraph } from "./builder.js"; import { buildReasoningState, COMPARABILITY_REASONING_NODE_ID, formulateQuestion, formulateTieResolutionQuestion, } from "./question-formulator.js"; import { graphUpdateSchema, 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 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 applied = applyGraphUpdate(graphSnapshot, proposalSnapshot); if (!applied.success) { return { success: false, stage: "application", errors: applied.errors, }; } const updatedSituationGraph = { ...graphSnapshot, nodes: applied.nodes, edges: applied.edges, resolvedNodeIds: applied.resolvedNodeIds, }; const 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; } const 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, ...formulateTieResolutionQuestion({ graph: updatedSituationGraph }), } : deterministicSelection?.status === "selected" ? { nodeId: deterministicSelection.nodeId, question: formulatedQuestion?.question || deterministicSelection.question, reason: formulatedQuestion?.reason || deterministicSelection.reason, strategy: formulatedQuestion?.strategy, investigationStrategy: formulatedQuestion?.investigationStrategy, } : (() => { const relationshipFallback = formulateTieResolutionQuestion({ graph: updatedSituationGraph, }); return relationshipFallback?.question ? { nodeId: null, question: relationshipFallback.question, reason: relationshipFallback.reason, strategy: relationshipFallback.strategy, investigationStrategy: relationshipFallback.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: validatedProposal, affectedNodeIds, resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, resolvedReasoningNodeIds: reasoningResolution.resolvedReasoningNodeIds, previousActiveUnknownNodeId, newActiveUnknownNodeId, selectedQuestion: finalSelectedQuestion, changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds), graphReferenceValidation: resultReferenceValidation, previousReasoningState: reasoningResolution.previousReasoningState, reasoningState: nextReasoningState, }; }