import { describeGraph } from "./builder.js"; import { assessUnknownAnswerability, assessUnknownAtomicity, buildReasoningState, classifyObservationRelationship, COMPARABILITY_REASONING_NODE_ID, formulateQuestion, formulateTieResolutionQuestion, } from "./question-formulator.js"; import { graphUpdateSchema, makeNodeId, situationGraphSchema, } from "./schema.js"; import { applyGraphUpdate, detectDuplicateNodeIds, findAffectedNodes, scoreUnknownCandidate, selectActiveUnknownCandidate, validateGraphReferences, validateGraphUpdate, } from "./utils.js"; function cloneJsonSafe(value) { return JSON.parse(JSON.stringify(value)); } function zodIssuesToErrors(error) { return ( error?.issues?.map((issue) => { const path = issue.path?.length ? `${issue.path.join(".")}: ` : ""; return `${path}${issue.message}`; }) ?? ["Validation failed"] ); } function collectDuplicateEdgeIds(edges) { const counts = new Map(); for (const edge of edges) { counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1); } return [...counts.entries()] .filter(([, count]) => count > 1) .map(([edgeId, count]) => ({ edgeId, count })); } function normaliseText(value) { return String(value || "") .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .trim(); } function buildNodeById(graph, addedNodes = []) { return new Map( [...graph.nodes, ...addedNodes].map((node) => [node.id, node]), ); } function isCompoundQuestion(question) { if (typeof question !== "string") return false; const trimmed = question.trim(); if (!trimmed) return false; const questionMarks = (trimmed.match(/\?/g) || []).length; if (questionMarks > 1) return true; if (/\?\s*(and|or)\b/i.test(trimmed)) return true; if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed)) return true; return false; } function validateAddedUnknowns(graph, proposal) { const errors = []; const addedUnknowns = proposal.addedNodes.filter( (node) => node.kind === "unknown", ); if (addedUnknowns.length > 3) { errors.push( `Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`, ); } const unresolvedExistingUnknowns = graph.nodes.filter( (node) => node.kind === "unknown" && !proposal.resolvedUnknownNodeIds.includes(node.id), ); const seenAddedUnknownMeanings = new Map(); const answerDerivedNodeIds = new Set([ ...proposal.updatedNodes.map((update) => update.nodeId), ...proposal.resolvedUnknownNodeIds, ...proposal.addedNodes .filter((node) => node.kind !== "unknown") .map((node) => node.id), ]); const proposalNodeById = buildNodeById(graph, proposal.addedNodes); function hasExplicitNodeReference(fromNode, toNodeId) { if (!fromNode || !toNodeId) return false; return ( fromNode.parentId === toNodeId || fromNode.dependsOn.includes(toNodeId) || fromNode.affects.includes(toNodeId) || fromNode.childIds.includes(toNodeId) ); } function hasExplicitAnswerDerivedRelationship(unknownNode) { const connectedEdge = proposal.addedEdges.find( (edge) => (edge.fromNodeId === unknownNode.id && answerDerivedNodeIds.has(edge.toNodeId)) || (edge.toNodeId === unknownNode.id && answerDerivedNodeIds.has(edge.fromNodeId)), ); if (connectedEdge) { return true; } for (const answerDerivedNodeId of answerDerivedNodeIds) { const answerDerivedNode = proposalNodeById.get(answerDerivedNodeId); if ( hasExplicitNodeReference(unknownNode, answerDerivedNodeId) || hasExplicitNodeReference(answerDerivedNode, unknownNode.id) ) { return true; } } return false; } for (const unknownNode of addedUnknowns) { const meaningKeys = [ normaliseText(unknownNode.label), normaliseText(unknownNode.description), ].filter(Boolean); for (const meaningKey of meaningKeys) { if (seenAddedUnknownMeanings.has(meaningKey)) { errors.push( `Proposal adds duplicate unknown meaning: "${unknownNode.label}"`, ); break; } seenAddedUnknownMeanings.set(meaningKey, unknownNode.id); } for (const existingUnknown of unresolvedExistingUnknowns) { const existingMeaningKeys = [ normaliseText(existingUnknown.label), normaliseText(existingUnknown.description), ].filter(Boolean); if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) { errors.push( `Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`, ); break; } } if ( unknownNode.description.trim() === unknownNode.label.trim() || !/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test( unknownNode.description, ) ) { errors.push( `New unknown must include why it matters in its description: "${unknownNode.id}"`, ); } if (!hasExplicitAnswerDerivedRelationship(unknownNode)) { errors.push( `New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`, ); } } return errors; } function validateSelectedQuestion(graph, proposal) { const errors = []; const selectedQuestion = proposal.selectedQuestion; const nodeById = buildNodeById(graph, proposal.addedNodes); if (selectedQuestion == null) { return { errors, selectedQuestionNodeId: null }; } const node = nodeById.get(selectedQuestion.nodeId); if (!node) { errors.push( `selectedQuestion references missing node: "${selectedQuestion.nodeId}"`, ); return { errors, selectedQuestionNodeId: selectedQuestion.nodeId }; } if (node.kind !== "unknown") { errors.push( `selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`, ); } const resolvesNode = proposal.resolvedUnknownNodeIds.includes( selectedQuestion.nodeId, ); const updatedStatus = proposal.updatedNodes.find( (update) => update.nodeId === selectedQuestion.nodeId, )?.newStatus; const effectiveStatus = updatedStatus ?? node.status; if (resolvesNode || effectiveStatus === "resolved") { errors.push( `selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`, ); } if ( graph.activeUnknownNodeId && proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) && selectedQuestion.nodeId === graph.activeUnknownNodeId ) { errors.push( `selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`, ); } if (isCompoundQuestion(selectedQuestion.question)) { errors.push("selectedQuestion must be a single non-compound question"); } const resolvedNodeIds = [ ...(graph.resolvedNodeIds || []), ...(proposal.resolvedUnknownNodeIds || []), ]; const candidateScore = scoreUnknownCandidate( { ...graph, nodes: [...graph.nodes, ...(proposal.addedNodes || [])], edges: [...graph.edges, ...(proposal.addedEdges || [])], }, node, resolvedNodeIds, ); return { errors, selectedQuestionNodeId: selectedQuestion.nodeId }; } function validateQuestionSelectionRequirement(graph, proposal) { const addedConsequentialUnknowns = proposal.addedNodes.filter( (node) => node.kind === "unknown" && node.status !== "resolved", ); if ( proposal.selectedQuestion == null && addedConsequentialUnknowns.length > 0 ) { return [ "selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown", ]; } return []; } function buildResolvedUnknownUpdate(node) { return { nodeId: node.id, previousStatus: node.status ?? null, newStatus: "resolved", previousValue: node.value ?? null, newValue: node.value ?? null, reason: "Resolved because the proposal explicitly marked this unknown as resolved.", }; } function reconcileResolutionSemantics(graph, proposal) { const nextProposal = cloneJsonSafe(proposal); const errors = []; const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node])); const updatedNodeById = new Map( nextProposal.updatedNodes.map((nodeUpdate) => [ nodeUpdate.nodeId, nodeUpdate, ]), ); for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) { const existingNode = graphNodeById.get(resolvedUnknownNodeId); if (!existingNode) { errors.push( `Resolved unknown must reference an existing node: "${resolvedUnknownNodeId}"`, ); continue; } if (existingNode.kind !== "unknown") { errors.push( `Resolved unknown must reference an existing unknown node: "${resolvedUnknownNodeId}"`, ); continue; } const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId); if (!existingUpdate) { const syntheticUpdate = buildResolvedUnknownUpdate(existingNode); nextProposal.updatedNodes.push(syntheticUpdate); updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate); continue; } if (existingUpdate.newStatus !== "resolved") { existingUpdate.newStatus = "resolved"; if (existingUpdate.previousStatus == null) { existingUpdate.previousStatus = existingNode.status ?? null; } if (existingUpdate.previousValue === undefined) { existingUpdate.previousValue = existingNode.value ?? null; } } } for (const update of nextProposal.updatedNodes) { const existingNode = graphNodeById.get(update.nodeId); if ( existingNode?.kind === "unknown" && update.newStatus === "resolved" && !nextProposal.resolvedUnknownNodeIds.includes(update.nodeId) ) { errors.push( `Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`, ); } } return { proposal: nextProposal, errors, }; } function validateSemanticDuplicateUnknowns(graph, proposal) { const errors = []; const unresolvedUnknowns = graph.nodes.filter( (node) => node.kind === "unknown" && !proposal.resolvedUnknownNodeIds.includes(node.id), ); for (const addedNode of proposal.addedNodes) { const addedTexts = [ normaliseText(addedNode.label), normaliseText(addedNode.description), ].filter(Boolean); for (const unresolvedUnknown of unresolvedUnknowns) { const unresolvedTexts = [ normaliseText(unresolvedUnknown.label), normaliseText(unresolvedUnknown.description), ].filter(Boolean); const duplicatesMeaning = addedTexts.some((text) => unresolvedTexts.includes(text), ); if (!duplicatesMeaning) continue; const linkedToUnknown = proposal.addedEdges.some( (edge) => (edge.fromNodeId === addedNode.id && edge.toNodeId === unresolvedUnknown.id) || (edge.toNodeId === addedNode.id && edge.fromNodeId === unresolvedUnknown.id), ); const updatedUnknown = proposal.updatedNodes.some( (update) => update.nodeId === unresolvedUnknown.id, ); if (!linkedToUnknown && !updatedUnknown) { errors.push( `Proposal adds a node duplicating unresolved unknown meaning without linking or resolving it: "${unresolvedUnknown.id}"`, ); } } } return errors; } function buildAffectedNodeIds(graph, proposal) { const affected = new Set(proposal.affectedNodeIds ?? []); for (const update of proposal.updatedNodes ?? []) { affected.add(update.nodeId); for (const nodeId of findAffectedNodes(graph, update.nodeId)) { affected.add(nodeId); } } for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) { affected.add(nodeId); for (const affectedNodeId of findAffectedNodes(graph, nodeId)) { affected.add(affectedNodeId); } } return [...affected]; } function buildChangesApplied(proposal, affectedNodeIds) { return { addedNodeCount: proposal.addedNodes.length, addedUnknownCount: proposal.addedNodes.filter( (node) => node.kind === "unknown", ).length, updatedNodeCount: proposal.updatedNodes.length, addedEdgeCount: proposal.addedEdges.length, removedEdgeCount: proposal.removedEdgeIds.length, resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length, affectedNodeCount: affectedNodeIds.length, }; } function appendUniqueValue(values = [], nextValue) { return nextValue && !values.includes(nextValue) ? [...values, nextValue] : values; } function getNodeConfidenceAssessment(node) { return ( node?.confidenceAssessment || { evidenceConfidence: node?.confidence ?? "medium", completenessStatus: node?.status === "resolved" ? "complete" : node?.status === "provisional" ? "partial" : "empty", conclusionConfidence: node?.status === "resolved" ? (node?.confidence ?? "high") : node?.status === "provisional" ? (node?.confidence ?? "medium") : "low", } ); } function confidenceFromAssessment(assessment) { return assessment?.conclusionConfidence ?? "medium"; } function unique(values = []) { return [...new Set(values.filter(Boolean))]; } function branchEvidenceKeys(node) { return unique([...(node?.evidenceIds || []), node?.value]); } function sharedMeaningfulTokens(aText, bText) { const stop = new Set([ "the", "and", "for", "that", "this", "with", "from", "because", "need", "unknown", "possible", ]); const a = splitSemanticTokens(aText).filter((token) => !stop.has(token)); const b = splitSemanticTokens(bText).filter((token) => !stop.has(token)); return [...new Set(a.filter((token) => b.includes(token)))]; } function branchConflictSignature(node) { return normaliseText( `${node?.label || ""} ${node?.description || ""} ${node?.value || ""}`, ); } function branchesConflict(aNode, bNode) { const aText = branchConflictSignature(aNode); const bText = branchConflictSignature(bNode); const oppositePolarity = (aText.includes("correctly") && bText.includes("incorrectly")) || (aText.includes("incorrectly") && bText.includes("correctly")) || aNode?.status === "contradicted" || bNode?.status === "contradicted"; if (!oppositePolarity) return false; return sharedMeaningfulTokens(aText, bText).length >= 2; } export function evaluateBranchInteractions({ parentNode, graph }) { const directBranches = findDirectChildUnknowns(graph, parentNode.id).filter( (node) => ["resolved", "provisional", "contradicted"].includes(node.status), ); const duplicateEvidenceGroups = []; const conflictingBranches = []; const corroboratingBranches = []; const duplicateBranchIds = new Set(); const conflictingBranchIds = new Set(); const evidenceGroups = new Map(); for (const branch of directBranches) { for (const evidenceKey of branchEvidenceKeys(branch)) { const ids = evidenceGroups.get(evidenceKey) || []; ids.push(branch.id); evidenceGroups.set(evidenceKey, ids); } } for (const [evidenceKey, branchIds] of evidenceGroups.entries()) { if (branchIds.length > 1) { duplicateEvidenceGroups.push({ evidenceKey, branchIds: unique(branchIds), }); for (const id of branchIds) duplicateBranchIds.add(id); } } for (let index = 0; index < directBranches.length; index += 1) { for (let inner = index + 1; inner < directBranches.length; inner += 1) { const aNode = directBranches[index]; const bNode = directBranches[inner]; if (branchesConflict(aNode, bNode)) { conflictingBranches.push([aNode.id, bNode.id]); conflictingBranchIds.add(aNode.id); conflictingBranchIds.add(bNode.id); continue; } const aEvidence = branchEvidenceKeys(aNode); const bEvidence = branchEvidenceKeys(bNode); const sharesEvidence = aEvidence.some((key) => bEvidence.includes(key)); if ( !sharesEvidence && aNode.status === "resolved" && bNode.status === "resolved" ) { corroboratingBranches.push([aNode.id, bNode.id]); } } } const interactionBranchIds = new Set([ ...duplicateBranchIds, ...conflictingBranchIds, ...corroboratingBranches.flat(), ]); const independentBranches = directBranches .map((branch) => branch.id) .filter((id) => !interactionBranchIds.has(id)); return { corroboratingBranches, conflictingBranches, duplicateEvidenceGroups, independentBranches, interactionSummary: { corroboratingBranchCount: corroboratingBranches.length, conflictingBranchCount: conflictingBranches.length, duplicateEvidenceCount: duplicateEvidenceGroups.length, independentBranchCount: independentBranches.length, }, }; } function upsertProposalNodeUpdate(proposalSnapshot, update) { const existing = proposalSnapshot.updatedNodes.find( (candidate) => candidate.nodeId === update.nodeId, ); if (existing) { if (update.newStatus != null) existing.newStatus = update.newStatus; if (update.newValue !== undefined) existing.newValue = update.newValue; if (existing.previousStatus == null) { existing.previousStatus = update.previousStatus ?? null; } if (existing.previousValue === undefined) { existing.previousValue = update.previousValue ?? null; } existing.reason = update.reason; return existing; } proposalSnapshot.updatedNodes.push(update); return update; } function ensureResolvedUnknownId(proposalSnapshot, nodeId) { if (!proposalSnapshot.resolvedUnknownNodeIds.includes(nodeId)) { proposalSnapshot.resolvedUnknownNodeIds.push(nodeId); } } function buildPropagationEvidenceId(nodeId) { return `answer:${nodeId}`; } function findDirectChildUnknowns(graph, parentNodeId) { const parentNode = (graph.nodes || []).find( (node) => node.id === parentNodeId, ); const childIds = new Set(parentNode?.childIds || []); for (const edge of graph.edges || []) { if (edge.toNodeId === parentNodeId && edge.relationship === "depends_on") { childIds.add(edge.fromNodeId); } } return (graph.nodes || []).filter( (node) => node.kind === "unknown" && (node.parentId === parentNodeId || childIds.has(node.id)), ); } function hasExistingDecompositionChildren(graph, parentNodeId) { return findDirectChildUnknowns(graph, parentNodeId).length > 0; } function buildAncestorChain(graph, node) { const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item])); const chain = []; const queue = [node?.parentId ?? null].filter(Boolean); const seen = new Set(); while (queue.length > 0) { const currentParentId = queue.shift(); if (!currentParentId || seen.has(currentParentId)) continue; seen.add(currentParentId); const parentNode = nodesById.get(currentParentId); if (!parentNode) continue; chain.push(parentNode); if (parentNode.parentId) { queue.push(parentNode.parentId); } for (const candidate of graph.nodes || []) { if ( candidate.id !== parentNode.id && (candidate.childIds || []).includes(parentNode.id) ) { queue.push(candidate.id); } } } return chain; } function syncParentChildReferences(graph) { const nodesById = new Map((graph.nodes || []).map((node) => [node.id, node])); for (const node of graph.nodes || []) { if (!node.parentId) continue; const parentNode = nodesById.get(node.parentId); if (!parentNode) continue; parentNode.childIds = appendUniqueValue(parentNode.childIds || [], node.id); parentNode.dependsOn = appendUniqueValue( parentNode.dependsOn || [], node.id, ); } return graph; } function computeParentProgressState(graph, parentNode) { const childUnknowns = findDirectChildUnknowns(graph, parentNode.id); const resolvedChildren = childUnknowns.filter( (child) => child.status === "resolved", ); const progressedChildren = childUnknowns.filter((child) => ["resolved", "provisional"].includes(child.status), ); const contradictoryChildren = childUnknowns.filter( (child) => child.status === "contradicted", ); const totalChildren = childUnknowns.length; const resolvedCount = resolvedChildren.length; const unresolvedCount = childUnknowns.filter( (child) => child.status !== "resolved", ).length; const beforeAssessment = getNodeConfidenceAssessment(parentNode); const interactions = evaluateBranchInteractions({ parentNode, graph }); const corroborationCount = interactions.interactionSummary.corroboratingBranchCount; const duplicateEvidenceCount = interactions.interactionSummary.duplicateEvidenceCount; const conflictingBranchCount = interactions.interactionSummary.conflictingBranchCount; if (totalChildren === 0) { const nextAssessment = { evidenceConfidence: beforeAssessment.evidenceConfidence, completenessStatus: beforeAssessment.completenessStatus, conclusionConfidence: beforeAssessment.conclusionConfidence, }; return { totalChildren, resolvedChildren, progressedChildren, contradictoryChildren, nextStatus: parentNode.status, nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: parentNode.status === "resolved", confidenceCapReason: "no_child_structure", reason: "Parent has no child unknowns to aggregate.", }; } let nextAssessment; let confidenceCapReason; if (contradictoryChildren.length > 0) { nextAssessment = { evidenceConfidence: resolvedCount > 0 ? "medium" : "low", completenessStatus: resolvedCount === 0 ? "empty" : "partial", conclusionConfidence: "low", }; confidenceCapReason = "contradictory_direct_children"; } else if (resolvedCount === 0) { nextAssessment = { evidenceConfidence: "low", completenessStatus: "empty", conclusionConfidence: "low", }; confidenceCapReason = "no_resolved_direct_children"; } else if (resolvedCount < totalChildren) { nextAssessment = { evidenceConfidence: corroborationCount > 0 ? "high" : "medium", completenessStatus: "partial", conclusionConfidence: "medium", }; confidenceCapReason = conflictingBranchCount > 0 ? "conflicting_branches_cap_conclusion" : duplicateEvidenceCount > 0 ? "duplicate_evidence_no_extra_confidence" : corroborationCount > 0 ? "independent_corroboration_with_incomplete_parent" : "unresolved_direct_children_cap_conclusion"; } else { nextAssessment = { evidenceConfidence: "high", completenessStatus: "complete", conclusionConfidence: conflictingBranchCount > 0 ? "low" : "high", }; confidenceCapReason = conflictingBranchCount > 0 ? "conflicting_branches_cap_conclusion" : duplicateEvidenceCount > 0 ? "duplicate_evidence_no_extra_confidence" : corroborationCount > 0 ? "independent_corroboration_supported_conclusion" : null; } if (resolvedChildren.length === totalChildren) { return { totalChildren, resolvedChildren, progressedChildren, contradictoryChildren, nextStatus: "resolved", nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: true, resolvedDirectChildren: resolvedCount, unresolvedDirectChildren: unresolvedCount, contradictoryDirectChildren: contradictoryChildren.length, branchInteractions: interactions, confidenceCapReason, reason: "All direct child unknowns are resolved, so the parent can now resolve deterministically.", }; } if (progressedChildren.length > 0) { return { totalChildren, resolvedChildren, progressedChildren, contradictoryChildren, nextStatus: "provisional", nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: false, resolvedDirectChildren: resolvedCount, unresolvedDirectChildren: unresolvedCount, contradictoryDirectChildren: contradictoryChildren.length, branchInteractions: interactions, confidenceCapReason, reason: "At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.", }; } return { totalChildren, resolvedChildren, progressedChildren, contradictoryChildren, nextStatus: parentNode.status, nextConfidence: confidenceFromAssessment(nextAssessment), nextConfidenceAssessment: nextAssessment, parentResolved: parentNode.status === "resolved", resolvedDirectChildren: resolvedCount, unresolvedDirectChildren: unresolvedCount, contradictoryDirectChildren: contradictoryChildren.length, branchInteractions: interactions, confidenceCapReason, reason: "No direct child progress exists yet for the parent.", }; } export function propagateResolvedChildEvidence({ updatedSituationGraph, proposalSnapshot, }) { const resolvedChildNodes = (updatedSituationGraph.nodes || []).filter( (node) => node.kind === "unknown" && node.parentId && proposalSnapshot.resolvedUnknownNodeIds.includes(node.id), ); if (resolvedChildNodes.length === 0) { return { graph: updatedSituationGraph, proposalSnapshot, propagationPerformed: false, resolvedChildNodeId: null, parentNodeId: null, parentStatusBefore: null, parentStatusAfter: null, parentConfidenceBefore: null, parentConfidenceAfter: null, evidenceConfidenceBefore: null, evidenceConfidenceAfter: null, completenessBefore: null, completenessAfter: null, conclusionConfidenceBefore: null, conclusionConfidenceAfter: null, resolvedDirectChildren: 0, unresolvedDirectChildren: 0, contradictoryDirectChildren: 0, confidenceCapReason: null, ancestorPropagationStoppedReason: "no_resolved_child_propagation_needed", affectedAncestorIds: [], nextSelectedSibling: null, parentResolved: false, reason: "No resolved decomposition child required upward propagation.", }; } const graph = cloneJsonSafe(updatedSituationGraph); syncParentChildReferences(graph); const propagationEvents = []; const affectedAncestorIds = new Set(); let ancestorPropagationStoppedReason = "no_ancestor_state_changed"; for (const resolvedChildNode of resolvedChildNodes) { const liveChildNode = graph.nodes.find( (node) => node.id === resolvedChildNode.id, ); if (!liveChildNode) continue; liveChildNode.evidenceIds = appendUniqueValue( liveChildNode.evidenceIds || [], buildPropagationEvidenceId(liveChildNode.id), ); const ancestorChain = buildAncestorChain(graph, liveChildNode); for (const ancestorNode of ancestorChain) { const beforeStatus = ancestorNode.status; const beforeConfidence = ancestorNode.confidence; const beforeAssessment = getNodeConfidenceAssessment(ancestorNode); const progressState = computeParentProgressState(graph, ancestorNode); ancestorNode.status = progressState.nextStatus; ancestorNode.confidence = progressState.nextConfidence; ancestorNode.confidenceAssessment = progressState.nextConfidenceAssessment; if ( beforeStatus === progressState.nextStatus && beforeConfidence === progressState.nextConfidence && JSON.stringify(beforeAssessment) === JSON.stringify(progressState.nextConfidenceAssessment) ) { continue; } ancestorPropagationStoppedReason = "ancestor_state_changed"; if (progressState.parentResolved) { ensureResolvedUnknownId(proposalSnapshot, ancestorNode.id); } upsertProposalNodeUpdate(proposalSnapshot, { nodeId: ancestorNode.id, previousStatus: beforeStatus, newStatus: progressState.nextStatus, previousValue: ancestorNode.value ?? null, newValue: ancestorNode.value ?? null, reason: progressState.reason, }); affectedAncestorIds.add(ancestorNode.id); propagationEvents.push({ resolvedChildNodeId: liveChildNode.id, parentNodeId: ancestorNode.id, parentStatusBefore: beforeStatus, parentStatusAfter: progressState.nextStatus, parentConfidenceBefore: beforeConfidence, parentConfidenceAfter: progressState.nextConfidence, evidenceConfidenceBefore: beforeAssessment.evidenceConfidence, evidenceConfidenceAfter: progressState.nextConfidenceAssessment.evidenceConfidence, completenessBefore: beforeAssessment.completenessStatus, completenessAfter: progressState.nextConfidenceAssessment.completenessStatus, conclusionConfidenceBefore: beforeAssessment.conclusionConfidence, conclusionConfidenceAfter: progressState.nextConfidenceAssessment.conclusionConfidence, resolvedDirectChildren: progressState.resolvedDirectChildren, unresolvedDirectChildren: progressState.unresolvedDirectChildren, contradictoryDirectChildren: progressState.contradictoryDirectChildren, corroboratingBranchCount: progressState.branchInteractions.interactionSummary .corroboratingBranchCount, conflictingBranchCount: progressState.branchInteractions.interactionSummary .conflictingBranchCount, duplicateEvidenceCount: progressState.branchInteractions.interactionSummary .duplicateEvidenceCount, independentBranchCount: progressState.branchInteractions.interactionSummary .independentBranchCount, interactionSummary: progressState.branchInteractions.interactionSummary, confidenceCapReason: progressState.confidenceCapReason, parentResolved: progressState.parentResolved, reason: progressState.reason, }); } } graph.resolvedNodeIds = [ ...new Set([ ...graph.resolvedNodeIds, ...proposalSnapshot.resolvedUnknownNodeIds, ]), ]; const siblingSelection = selectActiveUnknownCandidate( graph, graph.resolvedNodeIds, ); const firstEvent = propagationEvents[0] ?? null; return { graph, proposalSnapshot, propagationPerformed: propagationEvents.length > 0, resolvedChildNodeId: firstEvent?.resolvedChildNodeId ?? null, parentNodeId: firstEvent?.parentNodeId ?? null, parentStatusBefore: firstEvent?.parentStatusBefore ?? null, parentStatusAfter: firstEvent?.parentStatusAfter ?? null, parentConfidenceBefore: firstEvent?.parentConfidenceBefore ?? null, parentConfidenceAfter: firstEvent?.parentConfidenceAfter ?? null, evidenceConfidenceBefore: firstEvent?.evidenceConfidenceBefore ?? null, evidenceConfidenceAfter: firstEvent?.evidenceConfidenceAfter ?? null, completenessBefore: firstEvent?.completenessBefore ?? null, completenessAfter: firstEvent?.completenessAfter ?? null, conclusionConfidenceBefore: firstEvent?.conclusionConfidenceBefore ?? null, conclusionConfidenceAfter: firstEvent?.conclusionConfidenceAfter ?? null, resolvedDirectChildren: firstEvent?.resolvedDirectChildren ?? 0, unresolvedDirectChildren: firstEvent?.unresolvedDirectChildren ?? 0, contradictoryDirectChildren: firstEvent?.contradictoryDirectChildren ?? 0, corroboratingBranchCount: firstEvent?.corroboratingBranchCount ?? 0, conflictingBranchCount: firstEvent?.conflictingBranchCount ?? 0, duplicateEvidenceCount: firstEvent?.duplicateEvidenceCount ?? 0, independentBranchCount: firstEvent?.independentBranchCount ?? 0, interactionSummary: firstEvent?.interactionSummary ?? null, confidenceCapReason: firstEvent?.confidenceCapReason ?? null, ancestorPropagationStoppedReason, affectedAncestorIds: [...affectedAncestorIds], nextSelectedSibling: siblingSelection?.status === "selected" ? siblingSelection.nodeId : null, parentResolved: firstEvent?.parentResolved ?? false, reason: firstEvent?.reason ?? "Resolved child evidence propagated upward through the decomposition chain.", }; } function buildEmergentReasoningUnknownLabel(graph) { const central = String(graph?.centralStatement || "these observations") .trim() .replace(/[.?!:;]+$/g, ""); return `Explanation for why ${central}`; } function findEquivalentEmergentUnknown(graph, label, description) { const targetId = makeNodeId(label); const targetTexts = [normaliseText(label), normaliseText(description)].filter( Boolean, ); return (graph.nodes || []).find((node) => { if ( node.kind !== "unknown" || (graph.resolvedNodeIds || []).includes(node.id) ) { return false; } if (node.id === targetId) { return true; } const nodeTexts = [ normaliseText(node.label), normaliseText(node.description), ].filter(Boolean); return targetTexts.some((text) => nodeTexts.includes(text)); }); } function buildEmergentReasoningUnknown(graph, relationshipAssessment) { if (!relationshipAssessment?.relationshipAssessed) { return null; } if (!relationshipAssessment.questionRequired) { return null; } if ( ![ "potentially_related", "insufficient_information", "contradictory", ].includes(relationshipAssessment.relationshipStatus) ) { return null; } const label = buildEmergentReasoningUnknownLabel(graph); const description = "Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship."; const existingNode = findEquivalentEmergentUnknown(graph, label, description); if (existingNode) { return { created: false, node: existingNode, edges: [], reason: "Reused an existing unresolved reasoning unknown for the next investigation stage.", }; } const observationNodes = (graph.nodes || []).filter( (node) => node.kind === "observation" && node.status === "supported", ); const relationshipNode = (graph.nodes || []).find( (node) => node.kind === "relationship" && node.status === "supported", ); const nodeId = makeNodeId(label); const relatedNodeIds = relationshipNode ? [relationshipNode.id] : observationNodes.slice(0, 2).map((node) => node.id); if (relatedNodeIds.length === 0) { return null; } const node = { id: nodeId, label, description, kind: "unknown", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: relatedNodeIds, affects: [], parentId: relationshipNode?.id ?? null, childIds: [], }; const edges = relatedNodeIds.map((relatedNodeId) => ({ id: `e-${relatedNodeId.slice(0, 6)}-${nodeId.slice(0, 6)}`, fromNodeId: relatedNodeId, toNodeId: nodeId, relationship: relationshipNode?.id === relatedNodeId ? "depends_on" : "other", confidence: "medium", description: "This unresolved explanation arises from the now-assessed relationship between the observations.", })); return { created: true, node, edges, reason: "Created a new unresolved reasoning unknown so the next justified question is backed by the graph.", }; } function stripTrailingPunctuation(value) { return String(value || "") .trim() .replace(/[.?!:;]+$/g, "") .trim(); } function collectSupportedObservations(graph) { return (graph.nodes || []).filter( (node) => node.kind === "observation" && node.status === "supported", ); } function detectObservationConcept(text) { const normalised = normaliseText(text); const concepts = [ ["revenue", /\brevenue\b/], ["cash", /\bcash\b/], ["customer satisfaction", /\bsatisfaction\b/], ["complaints", /\bcomplaints?\b/], ["delivery time", /\bdelivery time\b|\bdelivery\b/], ["cancellations", /\bcancellations?\b/], ["traffic", /\btraffic\b/], ["sales", /\bsales\b/], ["production", /\bproduction\b|\boutput\b/], ["defects", /\bdefects?\b/], ["quality", /\bquality\b/], ]; for (const [label, pattern] of concepts) { if (pattern.test(normalised)) return label; } return null; } function buildDecompositionContext(graph) { const observations = collectSupportedObservations(graph); const firstObservation = observations[0] ?? null; const secondObservation = observations[1] ?? null; const firstConcept = detectObservationConcept( `${firstObservation?.label || ""} ${firstObservation?.description || ""}`, ); const secondConcept = detectObservationConcept( `${secondObservation?.label || ""} ${secondObservation?.description || ""}`, ); return { centralStatement: stripTrailingPunctuation(graph.centralStatement), firstConcept: firstConcept || "the first signal", secondConcept: secondConcept || "the second signal", firstObservationLabel: stripTrailingPunctuation( firstObservation?.label || "", ), secondObservationLabel: stripTrailingPunctuation( secondObservation?.label || "", ), }; } export const MAX_DECOMPOSITION_DEPTH = 2; function splitSemanticTokens(value) { return normaliseText(value) .split(" ") .filter((token) => token.length > 2); } function buildSemanticSignature(node) { return normaliseText(`${node?.label || ""} ${node?.description || ""}`); } function calculateTokenOverlapRatio(aTokens, bTokens) { const a = new Set(aTokens); const b = new Set(bTokens); const intersection = [...a].filter((token) => b.has(token)).length; const largest = Math.max(a.size, b.size, 1); return intersection / largest; } function detectCompoundSignals(text) { const signals = []; if (/\b(and|or)\b/.test(text)) { if ( /\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b[^.]{0,30}\b(and|or)\b[^.]{0,30}\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b/.test( text, ) ) { signals.push("conjoined_distinct_concepts"); } } if (/\b[a-z]+\s*\/\s*[a-z]+\b/.test(text)) { signals.push("slash_separated_categories"); } if (/,[^,]{0,20},/.test(text) || /,\s*[^,]+\s+or\s+[^,]+/.test(text)) { signals.push("comma_separated_category_list"); } if (/\btiming or measurement basis\b/.test(text)) { signals.push("timing_or_measurement_basis"); } return [...new Set(signals)]; } function isDirectlyAnswerableChildText(text) { return !/\b(explanation for why|possible causes|possible reasons|what changed|difference between|divergence|moved differently|factors behind|factors affecting)\b/.test( text, ); } function buildRejectedChildRecord(childNode, reasons, compoundSignals) { return { nodeId: childNode.id, label: childNode.label, reasons, compoundSignals, }; } function mergeUniqueRecords(existing = [], incoming = [], key = "nodeId") { const merged = new Map((existing || []).map((item) => [item[key], item])); for (const item of incoming || []) { merged.set(item[key], item); } return [...merged.values()]; } function cloneNode(node) { return JSON.parse(JSON.stringify(node)); } export function assessChildUnknownQuality({ parentNode, childNode, siblingNodes, graph, }) { const parentSignature = buildSemanticSignature(parentNode); const childSignature = buildSemanticSignature(childNode); const parentTokens = splitSemanticTokens(parentSignature); const childTokens = splitSemanticTokens(childSignature); const compoundSignals = detectCompoundSignals(childSignature); const duplicateSiblingIds = (siblingNodes || []) .filter((sibling) => sibling.id !== childNode.id) .filter((sibling) => buildSemanticSignature(sibling) === childSignature) .map((sibling) => sibling.id); const reasons = []; const narrowerThanParent = childSignature !== parentSignature && (childTokens.length < parentTokens.length || calculateTokenOverlapRatio(parentTokens, childTokens) < 0.8); const directlyAnswerable = isDirectlyAnswerableChildText(childSignature) && compoundSignals.length === 0; const independent = duplicateSiblingIds.length === 0 && compoundSignals.length === 0; const atomic = narrowerThanParent && directlyAnswerable && independent; if (!narrowerThanParent) { reasons.push("not_narrower_than_parent"); } if (!directlyAnswerable) { reasons.push("not_directly_answerable"); } if (compoundSignals.length > 0) { reasons.push("compound_child"); } if (duplicateSiblingIds.length > 0) { reasons.push("duplicate_sibling"); } if ((graph?.resolvedNodeIds || []).includes(childNode.id)) { reasons.push("already_resolved"); } return { valid: reasons.length === 0, atomic, directlyAnswerable, independent, narrowerThanParent, compoundSignals, duplicateSiblingIds, reasons, }; } function buildDecompositionChildId(parentNodeId, label) { return makeNodeId(`${parentNodeId}:${label}`); } function findEquivalentDecompositionChild( graph, parentNodeId, label, description, ) { const targetId = buildDecompositionChildId(parentNodeId, label); const targetTexts = [normaliseText(label), normaliseText(description)].filter( Boolean, ); return (graph.nodes || []).find((node) => { if ( node.kind !== "unknown" || node.parentId !== parentNodeId || (graph.resolvedNodeIds || []).includes(node.id) ) { return false; } if (node.id === targetId) { return true; } const nodeTexts = [ normaliseText(node.label), normaliseText(node.description), ].filter(Boolean); return targetTexts.some((text) => nodeTexts.includes(text)); }); } function describeObservationFocus(context, which) { const concept = which === "first" ? context.firstConcept : context.secondConcept; const label = which === "first" ? context.firstObservationLabel : context.secondObservationLabel; if (concept && !concept.startsWith("the ")) return concept; if (label) return label.toLowerCase(); return which === "first" ? "the first observation" : "the second observation"; } function buildDecompositionTemplates(parentNode, graph, depth = 0) { const parentText = normaliseText( `${parentNode?.label || ""} ${parentNode?.description || ""}`, ); const context = buildDecompositionContext(graph); const firstFocus = describeObservationFocus(context, "first"); const secondFocus = describeObservationFocus(context, "second"); if ( /\b(genuine problem|commercially justified|commercial justification|people would value|pay for it|justified confidence|decision support methods|willingness to pay|seek help)\b/.test( parentText, ) ) { return [ { label: "Who experiences this problem", description: "Need to know who experiences this problem, because that must be clear before deciding whether it is commercially justified.", dependsOnLabels: [], }, { label: "What happens when this problem is not resolved", description: "Need to know what happens when this problem is not resolved, because that is needed before judging whether the problem matters.", dependsOnLabels: ["Who experiences this problem"], }, { label: "How often this problem happens", description: "Need to know how often this problem happens, because that helps judge whether it is a real recurring problem.", dependsOnLabels: ["Who experiences this problem"], }, { label: "How people deal with this problem today", description: "Need to know how people deal with this problem today, because that is needed before comparing alternatives or value.", dependsOnLabels: [ "Who experiences this problem", "What happens when this problem is not resolved", "How often this problem happens", ], }, depth === 0 ? { label: "Whether people actively look for help with this problem", description: "Need to know whether people actively look for help with this problem, because that is needed before judging demand or willingness to pay.", dependsOnLabels: [ "Who experiences this problem", "What happens when this problem is not resolved", "How often this problem happens", "How people deal with this problem today", ], } : { label: "Whether people would pay to solve this problem", description: "Need to know whether people would pay to solve this problem, because that can only be judged after the problem itself is established.", dependsOnLabels: [ "Who experiences this problem", "What happens when this problem is not resolved", "How often this problem happens", "How people deal with this problem today", "Whether people actively look for help with this problem", ], }, ]; } if (/\btiming or measurement basis\b/i.test(parentNode.label)) { return [ { label: "Whether the two observations reflect different timing", description: `Need to know whether the two observations reflect different timing, because that would help resolve ${context.centralStatement}.`, }, { label: "How the two observations were measured", description: `Need evidence about the measure used for each observation, because that would help resolve ${context.centralStatement}.`, }, ]; } return [ { label: "Whether the two observations reflect different timing", description: `Need to know whether the two observations reflect different timing, because that could help explain ${context.centralStatement}.`, }, { label: "How the two observations were measured", description: `Need evidence about the measure used for each observation, because that could help explain ${context.centralStatement}.`, }, { label: `Possible change mainly affecting ${firstFocus}`, description: `Need to know whether a possible change mainly affected ${firstFocus}, because that could help explain ${context.centralStatement}.`, }, { label: `Possible change mainly affecting ${secondFocus}`, description: `Need to know whether a possible change mainly affected ${secondFocus}, because that could help explain ${context.centralStatement}.`, }, depth === 0 ? { label: "Possible one-off event during the period", description: `Need to know whether a possible one-off event happened during the period, because that could help explain ${context.centralStatement}.`, } : { label: "Mix shift during the period", description: `Need to know whether the mix of cases, customers, or items shifted during the period, because that could help explain ${context.centralStatement}.`, }, ]; } function buildCompositeUnknownChildren(parentNode, graph, depth = 0) { const templates = buildDecompositionTemplates(parentNode, graph, depth); const labelToId = new Map( templates.map((template) => [ template.label, buildDecompositionChildId(parentNode.id, template.label), ]), ); const candidateNodes = templates.map((template) => { const existing = findEquivalentDecompositionChild( graph, parentNode.id, template.label, template.description, ); const dependsOn = (template.dependsOnLabels || []) .map((label) => labelToId.get(label)) .filter(Boolean); return ( existing || { 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 isSelectableUnresolvedUnknown(graph, nodeId) { const node = findNodeById(graph, nodeId); return Boolean( node && node.kind === "unknown" && !["resolved", "contradicted"].includes(node.status) && !(graph.resolvedNodeIds || []).includes(node.id), ); } function selectedQuestionBelongsToChild(graph, selectedQuestion) { if (!selectedQuestion?.nodeId) return false; return Boolean(findNodeById(graph, selectedQuestion.nodeId)?.parentId); } function selectDecompositionChildCandidate(graph, parentNodeId) { const childCandidates = findDirectChildUnknowns(graph, parentNodeId).filter( (node) => node.kind === "unknown" && !["resolved", "contradicted"].includes(node.status), ); if (childCandidates.length === 0) { return { status: "none", nodeId: null, tiedCandidateIds: [] }; } const scored = childCandidates.map((node) => ({ node, score: scoreUnknownCandidate(graph, node, graph.resolvedNodeIds || []).score ?? Number.NEGATIVE_INFINITY, })); const topScore = Math.max(...scored.map((item) => item.score)); const top = scored.filter((item) => item.score === topScore); if (top.length === 0) { return { status: "none", nodeId: null, tiedCandidateIds: [] }; } if (top.length > 1) { return { status: "ambiguous", nodeId: null, tiedCandidateIds: top.map((item) => item.node.id), reason: "Multiple decomposition children remain equally good next investigations.", }; } return { status: "selected", nodeId: top[0].node.id, reason: "Selected the strongest direct child investigation for a non-answerable parent unknown.", }; } export function determineGraphBackedQuestion({ situationGraph }) { const graphSnapshot = cloneJsonSafe(situationGraph); let updatedSituationGraph = cloneJsonSafe(situationGraph); let deterministicSelection = selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds || [], ); const decompositionResult = runDeterministicDecomposition({ graphSnapshot, proposalSnapshot: { addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], selectedQuestion: null, }, updatedSituationGraph, reasoningResolution: { reasoningStateOverride: {} }, deterministicSelection, }); if (!decompositionResult.success) { return decompositionResult; } updatedSituationGraph = decompositionResult.updatedSituationGraph; updatedSituationGraph.reasoningState = buildReasoningState( updatedSituationGraph, ); deterministicSelection = isSelectableUnresolvedUnknown( updatedSituationGraph, decompositionResult.selectedChildNodeId, ) ? { status: "selected", nodeId: decompositionResult.selectedChildNodeId, reason: "Selected the preserved decomposition child because it remains the strongest independently answerable investigation.", } : selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds || [], ); updatedSituationGraph.activeUnknownNodeId = deterministicSelection?.status === "selected" ? deterministicSelection.nodeId : null; updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); const selectedNode = deterministicSelection?.status === "selected" ? findNodeById(updatedSituationGraph, deterministicSelection.nodeId) : null; const formulatedQuestion = selectedNode ? formulateQuestion({ node: selectedNode, graph: updatedSituationGraph, context: { selectionState: deterministicSelection }, }) : null; return { success: true, updatedSituationGraph, deterministicSelection, selectedQuestion: deterministicSelection?.status === "ambiguous" ? { id: "q_tie_resolution", ...formulateTieResolutionQuestion({ graph: updatedSituationGraph }), nodeId: null, tiedCandidateIds: deterministicSelection.tiedCandidateIds, } : deterministicSelection?.status === "selected" && formulatedQuestion ? { nodeId: deterministicSelection.nodeId, question: formulatedQuestion.question || deterministicSelection.question, reason: formulatedQuestion.reason, strategy: formulatedQuestion.strategy, investigationStrategy: formulatedQuestion.investigationStrategy, questionComplexity: formulatedQuestion.questionComplexity, plainLanguageNormalisations: formulatedQuestion.plainLanguageNormalisations, } : null, atomicityAssessment: decompositionResult.atomicityAssessment, answerabilityAssessment: decompositionResult.answerabilityAssessment, independentlyAnswerable: decompositionResult.answerabilityAssessment?.independentlyAnswerable ?? null, prerequisiteConceptCount: decompositionResult.answerabilityAssessment?.prerequisiteConceptCount ?? null, decompositionPerformed: decompositionResult.decompositionAttempted && decompositionResult.decompositionAccepted, decompositionAttempted: decompositionResult.decompositionAttempted, decompositionTriggeredByAnswerability: decompositionResult.decompositionTriggeredByAnswerability ?? false, selectedContainerUnknown: decompositionResult.selectedContainerUnknown ?? null, selectedChildUnknown: decompositionResult.selectedChildNodeId ?? (deterministicSelection?.status === "selected" ? deterministicSelection.nodeId : null), selectedUnknownBefore: decompositionResult.selectedUnknownBefore, selectedUnknownAfter: deterministicSelection?.nodeId ?? null, questionComplexityAssessment: formulatedQuestion?.questionComplexity ?? 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 lastAnswerabilityAssessment = null; let rootAtomicityAssessment = null; let rootAnswerabilityAssessment = null; let decompositionDepth = 0; let decompositionAttempted = false; let decompositionAccepted = false; let proposedChildCount = 0; let acceptedChildCount = 0; let selectedChildNodeId = null; let selectedUnknownBefore = deterministicSelection?.status === "selected" ? deterministicSelection.nodeId : null; let decompositionStoppedReason = null; let rejectedChildren = []; let childQualitySummary = []; let decompositionTriggeredByQuestionComplexity = false; let decompositionTriggeredByAnswerability = false; let selectedContainerUnknown = null; 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, }); const answerabilityAssessment = assessUnknownAnswerability({ node: selectedNode, graph: workingGraph, }); lastAtomicityAssessment = atomicityAssessment; lastAnswerabilityAssessment = answerabilityAssessment; if (!rootAtomicityAssessment) { rootAtomicityAssessment = atomicityAssessment; } if (!rootAnswerabilityAssessment) { rootAnswerabilityAssessment = answerabilityAssessment; } const decompositionRequired = atomicityAssessment.atomicity !== "atomic" || !answerabilityAssessment.independentlyAnswerable; if (!decompositionRequired) { selectedChildNodeId = decompositionDepth > 0 || selectedNode.parentId ? selectedNode.id : null; decompositionStoppedReason = decompositionDepth > 0 ? "Selected child is atomic and directly answerable." : "Selected unknown is already atomic."; break; } if (!selectedContainerUnknown) { selectedContainerUnknown = selectedNode.id; } if (!answerabilityAssessment.independentlyAnswerable) { decompositionTriggeredByAnswerability = true; } if (hasExistingDecompositionChildren(workingGraph, selectedNode.id)) { const childSelection = selectDecompositionChildCandidate( workingGraph, selectedNode.id, ); if (childSelection.status === "selected") { workingSelection = childSelection; decompositionStoppedReason = "Selected child is atomic and directly answerable."; selectedChildNodeId = findNodeById(workingGraph, childSelection.nodeId)?.parentId === selectedNode.id ? childSelection.nodeId : null; break; } if (childSelection.status === "ambiguous") { workingSelection = childSelection; decompositionStoppedReason = "Selected parent is not independently answerable and its existing child investigations are tied."; break; } decompositionStoppedReason = "Selected parent is not independently answerable, but no unresolved child investigation remained available."; 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 = selectDecompositionChildCandidate( workingGraph, selectedNode.id, ); 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; } if ( findNodeById(workingGraph, workingSelection.nodeId)?.parentId === selectedNode.id ) { selectedChildNodeId = workingSelection.nodeId; } decompositionAccepted = true; decompositionDepth += 1; } return { success: true, updatedSituationGraph: workingGraph, proposalSnapshot: workingProposal, reasoningState: nextReasoningState, deterministicSelection: workingSelection, atomicityAssessment: rootAtomicityAssessment ?? lastAtomicityAssessment ?? null, answerabilityAssessment: rootAnswerabilityAssessment ?? lastAnswerabilityAssessment ?? null, decompositionDepth, decompositionAttempted, decompositionAccepted, decompositionStoppedReason, proposedChildCount, acceptedChildCount, rejectedChildren, childQualitySummary, selectedChildNodeId, selectedUnknownBefore, decompositionTriggeredByQuestionComplexity, decompositionTriggeredByAnswerability, selectedContainerUnknown, }; } function isComparabilityQuestion(question) { const text = String(question || "").toLowerCase(); return ( text.includes("same basis") || text.includes("same scale") || text.includes("same period") ); } function answerConfirmsComparability(answer) { const text = String(answer || "").toLowerCase(); return ( /\byes\b/.test(text) && (text.includes("same accounting period") || text.includes("same management accounts") || text.includes("same basis") || text.includes("same scale") || text.includes("both figures cover the same")) ); } function deriveReasoningStateOverride({ graph, previousQuestion, answer, resolvedUnknownNodeIds, }) { const previousReasoningState = buildReasoningState(graph); const previousComparabilityStatus = previousReasoningState.comparabilityStatus ?? null; if ( previousComparabilityStatus === "uncertain" && isComparabilityQuestion(previousQuestion) && answerConfirmsComparability(answer) ) { return { reasoningStateOverride: { comparabilityStatus: "confirmed", comparabilityReason: "Comparability was confirmed by the user answer covering the same period and source basis.", comparabilityEvidence: resolvedUnknownNodeIds, }, resolvedReasoningNodeIds: [COMPARABILITY_REASONING_NODE_ID], previousReasoningState, }; } return { reasoningStateOverride: {}, resolvedReasoningNodeIds: [], previousReasoningState, }; } export function applyValidatedProposal({ situationGraph, proposal, previousQuestion = null, answer = null, }) { const graphValidation = situationGraphSchema.safeParse(situationGraph); const proposalValidation = graphUpdateSchema.safeParse(proposal); const existingGraphReferenceValidation = graphValidation.success ? validateGraphReferences(situationGraph) : null; const existingDuplicateNodeIds = graphValidation.success ? detectDuplicateNodeIds(situationGraph.nodes) : []; const existingDuplicateEdgeIds = graphValidation.success ? collectDuplicateEdgeIds(situationGraph.edges) : []; if ( !graphValidation.success || !existingGraphReferenceValidation?.valid || existingDuplicateNodeIds.length > 0 || existingDuplicateEdgeIds.length > 0 ) { return { success: false, stage: "graph_validation", errors: [ ...(!graphValidation.success ? zodIssuesToErrors(graphValidation.error) : []), ...(!existingGraphReferenceValidation?.valid ? existingGraphReferenceValidation.errors : []), ...existingDuplicateNodeIds.map( ({ nodeId, count }) => `Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`, ), ...existingDuplicateEdgeIds.map( ({ edgeId, count }) => `Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`, ), ], }; } if (!proposalValidation.success) { return { success: false, stage: "proposal_compatibility", errors: zodIssuesToErrors(proposalValidation.error), }; } const reconciledProposal = reconcileResolutionSemantics( situationGraph, proposalValidation.data, ); const validatedProposal = reconciledProposal.proposal; const proposalCompatibilityErrors = []; proposalCompatibilityErrors.push(...reconciledProposal.errors); const proposalGraphValidation = validateGraphUpdate( situationGraph, validatedProposal, ); if (!proposalGraphValidation.valid) { proposalCompatibilityErrors.push(...proposalGraphValidation.errors); } const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id)); const reachableNodeIds = new Set([ ...situationGraph.nodes.map((node) => node.id), ...validatedProposal.addedNodes.map((node) => node.id), ]); const addedEdgeDuplicateIds = collectDuplicateEdgeIds( validatedProposal.addedEdges, ); proposalCompatibilityErrors.push( ...addedEdgeDuplicateIds.map( ({ edgeId, count }) => `Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`, ), ); for (const edge of validatedProposal.addedEdges) { if (existingEdgeIds.has(edge.id)) { proposalCompatibilityErrors.push( `Cannot add edge with duplicate ID: "${edge.id}"`, ); } if (!reachableNodeIds.has(edge.fromNodeId)) { proposalCompatibilityErrors.push( `Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`, ); } if (!reachableNodeIds.has(edge.toNodeId)) { proposalCompatibilityErrors.push( `Added edge references non-existent toNodeId: "${edge.toNodeId}"`, ); } } const removedEdgeIds = new Set(validatedProposal.removedEdgeIds); for (const edgeId of removedEdgeIds) { if (!existingEdgeIds.has(edgeId)) { proposalCompatibilityErrors.push( `Cannot remove non-existent edge: "${edgeId}"`, ); } } const combinedNodeDuplicates = detectDuplicateNodeIds([ ...situationGraph.nodes, ...validatedProposal.addedNodes, ]); proposalCompatibilityErrors.push( ...combinedNodeDuplicates.map( ({ nodeId, count }) => `Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`, ), ); proposalCompatibilityErrors.push( ...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal), ); proposalCompatibilityErrors.push( ...validateAddedUnknowns(situationGraph, validatedProposal), ); const selectedQuestionValidation = validateSelectedQuestion( situationGraph, validatedProposal, ); proposalCompatibilityErrors.push(...selectedQuestionValidation.errors); proposalCompatibilityErrors.push( ...validateQuestionSelectionRequirement(situationGraph, validatedProposal), ); if (proposalCompatibilityErrors.length > 0) { return { success: false, stage: "proposal_compatibility", errors: proposalCompatibilityErrors, }; } const graphSnapshot = cloneJsonSafe(situationGraph); const proposalSnapshot = cloneJsonSafe(validatedProposal); const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null; const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot); const reasoningResolution = deriveReasoningStateOverride({ graph: graphSnapshot, previousQuestion, answer, resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, }); const provisionalApplied = applyGraphUpdate(graphSnapshot, proposalSnapshot); if (!provisionalApplied.success) { return { success: false, stage: "application", errors: provisionalApplied.errors, }; } const provisionalGraph = { ...graphSnapshot, nodes: provisionalApplied.nodes, edges: provisionalApplied.edges, resolvedNodeIds: provisionalApplied.resolvedNodeIds, }; provisionalGraph.reasoningState = buildReasoningState( provisionalGraph, reasoningResolution.reasoningStateOverride, ); const relationshipAssessment = classifyObservationRelationship(provisionalGraph); const emergentReasoningUnknown = buildEmergentReasoningUnknown( provisionalGraph, relationshipAssessment, ); if (emergentReasoningUnknown?.created) { proposalSnapshot.addedNodes.push(emergentReasoningUnknown.node); proposalSnapshot.addedEdges.push(...emergentReasoningUnknown.edges); } let applied = applyGraphUpdate(graphSnapshot, proposalSnapshot); if (!applied.success) { return { success: false, stage: "application", errors: applied.errors, }; } let updatedSituationGraph = { ...graphSnapshot, nodes: applied.nodes, edges: applied.edges, resolvedNodeIds: applied.resolvedNodeIds, }; let nextReasoningState = buildReasoningState( updatedSituationGraph, reasoningResolution.reasoningStateOverride, ); updatedSituationGraph.reasoningState = nextReasoningState; const activeUnknownWasResolved = previousActiveUnknownNodeId != null && updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId); let newActiveUnknownNodeId = previousActiveUnknownNodeId; if (activeUnknownWasResolved) { newActiveUnknownNodeId = null; } if (validatedProposal.selectedQuestion?.nodeId) { newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId; } const remainingUnknownExists = newActiveUnknownNodeId != null && updatedSituationGraph.nodes.some( (node) => node.id === newActiveUnknownNodeId && node.kind === "unknown" && !updatedSituationGraph.resolvedNodeIds.includes(node.id), ); if (!remainingUnknownExists) { newActiveUnknownNodeId = selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds, )?.nodeId ?? null; } let deterministicSelection = selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds, ); const decompositionResult = runDeterministicDecomposition({ graphSnapshot, proposalSnapshot, updatedSituationGraph, reasoningResolution, deterministicSelection, }); if (!decompositionResult.success) { return decompositionResult; } updatedSituationGraph = decompositionResult.updatedSituationGraph; nextReasoningState = decompositionResult.reasoningState; deterministicSelection = decompositionResult.deterministicSelection; const propagationResult = propagateResolvedChildEvidence({ updatedSituationGraph, proposalSnapshot: decompositionResult.proposalSnapshot, }); updatedSituationGraph = propagationResult.graph; nextReasoningState = buildReasoningState( updatedSituationGraph, reasoningResolution.reasoningStateOverride, ); updatedSituationGraph.reasoningState = nextReasoningState; deterministicSelection = isSelectableUnresolvedUnknown( updatedSituationGraph, decompositionResult.selectedChildNodeId, ) ? { status: "selected", nodeId: decompositionResult.selectedChildNodeId, reason: "Preserved the selected decomposition child because it remains unresolved after propagation.", } : selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds, ); const atomicityAssessment = decompositionResult.atomicityAssessment; const answerabilityAssessment = decompositionResult.answerabilityAssessment; const decompositionDepth = decompositionResult.decompositionDepth; const decompositionAttempted = decompositionResult.decompositionAttempted; const decompositionAccepted = decompositionResult.decompositionAccepted; const decompositionStoppedReason = decompositionResult.decompositionStoppedReason; const proposedChildCount = decompositionResult.proposedChildCount; const acceptedChildCount = decompositionResult.acceptedChildCount; const rejectedChildren = decompositionResult.rejectedChildren; const childQualitySummary = decompositionResult.childQualitySummary; const selectedChildNodeId = decompositionResult.selectedChildNodeId; const decompositionPerformed = decompositionAttempted && decompositionAccepted; const decompositionChildNodeIds = [ ...new Set( decompositionResult.proposalSnapshot.addedNodes .filter((node) => node.kind === "unknown" && node.parentId != null) .map((node) => node.id), ), ]; const decompositionReason = decompositionStoppedReason; const propagationPerformed = propagationResult.propagationPerformed; const resolvedChildNodeId = propagationResult.resolvedChildNodeId; const parentNodeId = propagationResult.parentNodeId; const parentStatusBefore = propagationResult.parentStatusBefore; const parentStatusAfter = propagationResult.parentStatusAfter; const parentConfidenceBefore = propagationResult.parentConfidenceBefore; const parentConfidenceAfter = propagationResult.parentConfidenceAfter; const evidenceConfidenceBefore = propagationResult.evidenceConfidenceBefore; const evidenceConfidenceAfter = propagationResult.evidenceConfidenceAfter; const completenessBefore = propagationResult.completenessBefore; const completenessAfter = propagationResult.completenessAfter; const conclusionConfidenceBefore = propagationResult.conclusionConfidenceBefore; const conclusionConfidenceAfter = propagationResult.conclusionConfidenceAfter; const resolvedDirectChildren = propagationResult.resolvedDirectChildren; const unresolvedDirectChildren = propagationResult.unresolvedDirectChildren; const contradictoryDirectChildren = propagationResult.contradictoryDirectChildren; const confidenceCapReason = propagationResult.confidenceCapReason; const ancestorPropagationStoppedReason = propagationResult.ancestorPropagationStoppedReason; const affectedAncestorIds = propagationResult.affectedAncestorIds; const nextSelectedSibling = propagationResult.nextSelectedSibling; const parentResolved = propagationResult.parentResolved; const corroboratingBranchCount = propagationResult.corroboratingBranchCount; const conflictingBranchCount = propagationResult.conflictingBranchCount; const duplicateEvidenceCount = propagationResult.duplicateEvidenceCount; const independentBranchCount = propagationResult.independentBranchCount; const interactionSummary = propagationResult.interactionSummary; const propagationReason = propagationResult.reason; if ( deterministicSelection?.status === "selected" && deterministicSelection?.nodeId ) { newActiveUnknownNodeId = deterministicSelection.nodeId; } else if (deterministicSelection?.status === "ambiguous") { newActiveUnknownNodeId = null; } updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId; updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); const selectedNode = deterministicSelection?.status === "selected" && deterministicSelection?.nodeId ? updatedSituationGraph.nodes.find( (node) => node.id === deterministicSelection.nodeId, ) : null; const formulatedQuestion = selectedNode ? formulateQuestion({ node: selectedNode, graph: updatedSituationGraph, context: { resolvedValues: validatedProposal.updatedNodes .map((update) => update.newValue) .filter( (value) => typeof value === "string" && value.trim().length > 0, ), selectionState: deterministicSelection, }, }) : null; const questionComplexity = formulatedQuestion?.questionComplexity ?? null; const plainLanguageNormalisations = formulatedQuestion?.plainLanguageNormalisations ?? []; 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, questionComplexity, plainLanguageNormalisations, } : null; const finalSelectedChildNodeId = selectedChildNodeId ?? (selectedQuestionBelongsToChild( updatedSituationGraph, finalSelectedQuestion, ) ? finalSelectedQuestion?.nodeId : 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, answerabilityAssessment, independentlyAnswerable: answerabilityAssessment?.independentlyAnswerable ?? null, prerequisiteConceptCount: answerabilityAssessment?.prerequisiteConceptCount ?? null, decompositionDepth, decompositionAttempted, decompositionAccepted, decompositionStoppedReason, proposedChildCount, acceptedChildCount, rejectedChildren, selectedChildNodeId: finalSelectedChildNodeId, childQualitySummary, selectedUnknownBefore: decompositionResult.selectedUnknownBefore, selectedUnknownAfter: deterministicSelection?.nodeId ?? null, questionComplexityAccepted: questionComplexity?.acceptable ?? null, primaryConceptCount: questionComplexity?.primaryConceptCount ?? null, cognitiveLoad: questionComplexity?.cognitiveLoad ?? null, complexityReasons: questionComplexity?.reasons ?? [], decompositionTriggeredByQuestionComplexity: decompositionResult.decompositionTriggeredByQuestionComplexity ?? false, decompositionTriggeredByAnswerability: decompositionResult.decompositionTriggeredByAnswerability ?? false, previousQuestion, finalQuestion: finalSelectedQuestion?.question ?? null, plainLanguageNormalisations, propagationPerformed, resolvedChildNodeId, parentNodeId, parentStatusBefore, parentStatusAfter, parentConfidenceBefore, parentConfidenceAfter, evidenceConfidenceBefore, evidenceConfidenceAfter, completenessBefore, completenessAfter, conclusionConfidenceBefore, conclusionConfidenceAfter, resolvedDirectChildren, unresolvedDirectChildren, contradictoryDirectChildren, corroboratingBranchCount, conflictingBranchCount, duplicateEvidenceCount, independentBranchCount, interactionSummary, confidenceCapReason, ancestorPropagationStoppedReason, affectedAncestorIds, nextSelectedSibling, parentResolved, decompositionPerformed, childUnknownCount: decompositionChildNodeIds.length, childNodeIds: decompositionChildNodeIds, selectedContainerUnknown: decompositionResult.selectedContainerUnknown ?? null, selectedChildUnknown: finalSelectedChildNodeId ?? (deterministicSelection?.status === "selected" ? deterministicSelection.nodeId : null), atomicityReason: propagationReason || decompositionReason || atomicityAssessment?.reason || null, previousActiveUnknownNodeId, newActiveUnknownNodeId, selectedQuestion: finalSelectedQuestion, changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds), graphReferenceValidation: resultReferenceValidation, previousReasoningState: reasoningResolution.previousReasoningState, reasoningState: nextReasoningState, }; }