Files
confidence-engine/lib/graph/apply-proposal.js
T

3584 lines
115 KiB
JavaScript

import { describeGraph } from "./builder.js";
import {
assessUnknownAnswerability,
assessUnknownAtomicity,
buildReasoningState,
classifyObservationRelationship,
COMPARABILITY_REASONING_NODE_ID,
formulateQuestion,
formulateTieResolutionQuestion,
selectReasoningPattern,
} 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: "Whether other people experience this problem",
description:
"Need to know whether other people experience this problem, because that must be established before deciding whether the problem is broadly important.",
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",
"Whether other people experience this problem",
],
},
{
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 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",
"Whether other people experience 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",
"Whether other people experience 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",
"Whether other people experience 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 listUnresolvedUnknownCandidates(
graph,
resolvedCurrentTurnNodeIds = [],
) {
const resolvedCurrentTurnSet = new Set(resolvedCurrentTurnNodeIds || []);
return (graph.nodes || []).filter(
(node) =>
node.kind === "unknown" &&
!["resolved", "contradicted"].includes(node.status) &&
!(graph.resolvedNodeIds || []).includes(node.id) &&
!resolvedCurrentTurnSet.has(node.id),
);
}
function listEligibleUnknownCandidates(graph, resolvedCurrentTurnNodeIds = []) {
return listUnresolvedUnknownCandidates(
graph,
resolvedCurrentTurnNodeIds,
).filter(
(node) =>
(scoreUnknownCandidate(graph, node, graph.resolvedNodeIds || [])
.unresolvedParentUnknownCount ?? 0) === 0,
);
}
function selectOrderedSiblingCandidate(
graph,
candidateNodeIds = [],
resolvedCurrentTurnNodeIds = [],
) {
const candidates = candidateNodeIds
.map((nodeId) => findNodeById(graph, nodeId))
.filter(Boolean);
if (candidates.length < 2) {
return null;
}
const parentId = candidates[0]?.parentId ?? null;
if (!parentId || !candidates.every((node) => node.parentId === parentId)) {
return null;
}
const parentNode = findNodeById(graph, parentId);
const orderedIds = (parentNode?.childIds || []).filter((nodeId) =>
candidateNodeIds.includes(nodeId),
);
const fallbackOrderedIds = (graph.nodes || [])
.filter((node) => candidateNodeIds.includes(node.id))
.map((node) => node.id);
const orderedCandidateIds =
orderedIds.length > 0 ? orderedIds : fallbackOrderedIds;
const eligibleIds = new Set(
listEligibleUnknownCandidates(graph, resolvedCurrentTurnNodeIds).map(
(node) => node.id,
),
);
const selectedId = orderedCandidateIds.find((nodeId) =>
eligibleIds.has(nodeId),
);
if (!selectedId) {
return null;
}
return {
status: "selected",
nodeId: selectedId,
reason:
"Resolved a sibling tie using the deterministic decomposition order after the update left multiple equally scored follow-up children.",
};
}
function selectedQuestionBelongsToChild(graph, selectedQuestion) {
if (!selectedQuestion?.nodeId) return false;
return Boolean(findNodeById(graph, selectedQuestion.nodeId)?.parentId);
}
function normaliseQuestionText(question) {
return normaliseText(String(question || "").replace(/\?/g, " "));
}
function semanticNodeSignature(node) {
return normaliseText(`${node?.label || ""} ${node?.description || ""}`);
}
function isStructurallyRepeatedQuestion({
previousQuestion,
previousNode,
nextQuestion,
nextNode,
nextQuestionFamily,
nextInvestigationStrategy,
}) {
if (!previousQuestion || !nextQuestion || !nextNode) {
return false;
}
const sameQuestion =
normaliseQuestionText(previousQuestion) ===
normaliseQuestionText(nextQuestion);
const previousSignature = previousNode
? semanticNodeSignature(previousNode)
: null;
const nextSignature = semanticNodeSignature(nextNode);
const sameSemanticTarget = previousSignature === nextSignature;
const sharedParent =
previousNode?.parentId &&
nextNode?.parentId &&
previousNode.parentId === nextNode.parentId;
const sameQuestionFamily = Boolean(previousNode && nextQuestionFamily);
const samePurpose = Boolean(nextQuestionFamily || nextInvestigationStrategy);
return (
sameQuestion &&
samePurpose &&
(sameSemanticTarget || sharedParent || sameQuestionFamily)
);
}
function buildRepeatedQuestionDiagnostics(nextNode) {
return `Rejected repeated follow-up for "${nextNode?.label || nextNode?.id || "unknown"}" because the previous answer did not justify asking the same structural question again while other eligible investigations may remain.`;
}
const ALLOWED_NODE_PATTERNS_BY_ACTIVE_PATTERN = {
decision: ["decision", "definition"],
explanation: ["explanation", "comparison", "definition"],
contradiction: ["contradiction", "comparison", "explanation", "definition"],
definition: ["definition"],
diagnosis: ["diagnosis", "comparison", "definition"],
comparison: ["comparison", "definition"],
prioritisation: ["prioritisation", "decision", "definition"],
};
function determineActiveReasoningPattern(node, graph) {
if (!node || !graph) {
return {
pattern: null,
reason: "No active reasoning pattern could be determined.",
};
}
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
let currentParentId = node.parentId;
while (currentParentId) {
const parentNode = nodesById.get(currentParentId);
if (!parentNode) break;
const parentSelection = selectReasoningPattern({ node: parentNode, graph });
if (parentSelection.pattern && parentSelection.pattern !== "definition") {
return {
pattern: parentSelection.pattern,
reason: `Inherited active reasoning pattern from parent node because ${parentSelection.reason}`,
};
}
currentParentId = parentNode.parentId;
}
const selection = selectReasoningPattern({ node, graph });
return {
pattern: selection.pattern,
reason: selection.reason,
};
}
function inferIntrinsicNodePattern(node, graph) {
const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`);
const observationCount = (graph.nodes || []).filter(
(candidate) =>
candidate.kind === "observation" && candidate.status === "supported",
).length;
if (
/\b(define|definition|meaning|term|terminology|boundaries)\b/.test(text)
) {
return "definition";
}
if (
/\b(contradiction|contradict|conflict|inconsistent|mismatch|opposing)\b/.test(
text,
)
) {
return "contradiction";
}
if (
/\b(two observations|measured|measurement|basis|scale|same period|different timing|comparable)\b/.test(
text,
)
) {
return "comparison";
}
if (
observationCount >= 2 &&
/\b(explain|explanation|what changed|difference between|divergence|moved differently)\b/.test(
text,
)
) {
return "explanation";
}
if (
/\b(genuine problem|who experiences|other people experience|how often this problem happens|what happens when this problem is not resolved|how people deal with this problem today|actively look for help|would pay to solve this problem|commercially justified|commercial justification|business case|value|demand|audience|customer|user|alternative|alternatives)\b/.test(
text,
)
) {
return "decision";
}
return selectReasoningPattern({ node, graph }).pattern;
}
function assessReasoningPatternCompatibility({ node, graph, activePattern }) {
if (!node || !activePattern) {
return {
compatible: true,
activePattern: activePattern ?? null,
nodePattern: null,
reason: "No active reasoning pattern constraint was applied.",
};
}
const nodePattern = inferIntrinsicNodePattern(node, graph);
const allowedPatterns = ALLOWED_NODE_PATTERNS_BY_ACTIVE_PATTERN[
activePattern
] ?? [activePattern];
const compatible = allowedPatterns.includes(nodePattern);
return {
compatible,
activePattern,
nodePattern,
allowedPatterns,
reason: compatible
? `Node remains compatible because ${nodePattern} is allowed during ${activePattern} reasoning.`
: `Node is incompatible because ${nodePattern} is not allowed during ${activePattern} reasoning.`,
};
}
function isExplicitComparisonFamilyUnknown(node) {
const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`);
return /\b(two observations|measured|measurement|basis|scale|same period|different timing|comparable)\b/.test(
text,
);
}
function buildCompatibilityFailure(node, compatibility, reason) {
return {
nodeId: node?.id ?? null,
label: node?.label ?? null,
activePattern: compatibility?.activePattern ?? null,
nodePattern: compatibility?.nodePattern ?? null,
allowedPatterns: compatibility?.allowedPatterns ?? [],
rejectionReason: reason || compatibility?.reason || null,
};
}
function buildRejectedSelectionDiagnostics({
node,
graph,
activePattern,
reason,
}) {
const compatibility = assessReasoningPatternCompatibility({
node,
graph,
activePattern,
});
return {
incompatibleNodeIds: node?.id ? [node.id] : [],
compatibilityFailures: [
buildCompatibilityFailure(node, compatibility, reason),
],
};
}
function selectPatternCompatibleUnknownCandidate({
graph,
resolvedNodeIds = [],
activePattern,
excludedNodeIds = [],
}) {
if (!activePattern) {
return selectActiveUnknownCandidate(graph, resolvedNodeIds);
}
const excluded = new Set(excludedNodeIds || []);
const incompatibleNodeIds = (graph.nodes || [])
.filter(
(node) =>
node.kind === "unknown" &&
!excluded.has(node.id) &&
!["resolved", "contradicted"].includes(node.status),
)
.filter(
(node) =>
!assessReasoningPatternCompatibility({
node,
graph,
activePattern,
}).compatible,
)
.map((node) => node.id);
return selectActiveUnknownCandidate(graph, [
...new Set([
...(resolvedNodeIds || []),
...incompatibleNodeIds,
...excludedNodeIds,
]),
]);
}
function collectPatternCompatibilityDiagnostics({
graph,
activePattern,
candidateNodeIds = [],
}) {
if (!activePattern) {
return {
reasoningPatternValidation: {
activePattern: null,
valid: true,
reason: "No active reasoning pattern constraint was applied.",
},
patternCompatibleNodeCount: 0,
incompatibleNodeIds: [],
compatibilityFailures: [],
graphReasoningIntegrity: "not_applicable",
};
}
const candidateSet = new Set(candidateNodeIds || []);
const compatibilityFailures = (graph.nodes || [])
.filter(
(node) =>
node.kind === "unknown" &&
candidateSet.has(node.id) &&
!["resolved", "contradicted"].includes(node.status),
)
.map((node) => ({
node,
compatibility: assessReasoningPatternCompatibility({
node,
graph,
activePattern,
}),
}))
.filter(({ compatibility }) => !compatibility.compatible)
.map(({ node, compatibility }) =>
buildCompatibilityFailure(node, compatibility),
);
return {
reasoningPatternValidation: {
activePattern,
valid: compatibilityFailures.length === 0,
reason:
compatibilityFailures.length === 0
? `All selectable unknowns are compatible with ${activePattern} reasoning.`
: `Some selectable unknowns are incompatible with ${activePattern} reasoning.`,
},
patternCompatibleNodeCount:
(candidateNodeIds || []).length - compatibilityFailures.length,
incompatibleNodeIds: compatibilityFailures.map((failure) => failure.nodeId),
compatibilityFailures,
graphReasoningIntegrity:
compatibilityFailures.length === 0 ? "valid" : "invalid",
};
}
function selectDecompositionChildCandidate(
graph,
parentNodeId,
activePattern = null,
) {
const childCandidates = findDirectChildUnknowns(graph, parentNodeId)
.filter(
(node) =>
node.kind === "unknown" &&
!["resolved", "contradicted"].includes(node.status),
)
.filter((node) => {
if (
activePattern === "decision" &&
isExplicitComparisonFamilyUnknown(node)
) {
return false;
}
const compatibility = assessReasoningPatternCompatibility({
node,
graph,
activePattern,
});
return compatibility.compatible;
});
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.",
};
}
function buildSelectedQuestionResult({
updatedSituationGraph,
deterministicSelection,
}) {
const selectedNode =
deterministicSelection?.status === "selected"
? findNodeById(updatedSituationGraph, deterministicSelection.nodeId)
: null;
const formulatedQuestion = selectedNode
? formulateQuestion({
node: selectedNode,
graph: updatedSituationGraph,
context: { selectionState: deterministicSelection },
})
: null;
const 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,
reasoningPattern: formulatedQuestion.reasoningPattern,
reasoningPatternReason: formulatedQuestion.reasoningPatternReason,
questionFamily: formulatedQuestion.questionFamily,
allowedQuestionFamilies: formulatedQuestion.allowedQuestionFamilies,
rejectedQuestionFamilies:
formulatedQuestion.rejectedQuestionFamilies,
selectedQuestionTemplate:
formulatedQuestion.selectedQuestionTemplate,
questionComplexity: formulatedQuestion.questionComplexity,
plainLanguageNormalisations:
formulatedQuestion.plainLanguageNormalisations,
}
: null;
return {
selectedNode,
formulatedQuestion,
selectedQuestion,
};
}
function resolveAmbiguousGraphBackedSelection({
graphSnapshot,
updatedSituationGraph,
deterministicSelection,
}) {
const orderedCandidateIds =
deterministicSelection?.displayOrder ||
deterministicSelection?.tiedCandidateIds ||
[];
for (const candidateNodeId of orderedCandidateIds) {
if (
!isSelectableUnresolvedUnknown(updatedSituationGraph, candidateNodeId)
) {
continue;
}
const candidateResult = runDeterministicDecomposition({
graphSnapshot,
proposalSnapshot: {
addedNodes: [],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
selectedQuestion: null,
},
updatedSituationGraph: cloneJsonSafe(updatedSituationGraph),
reasoningResolution: { reasoningStateOverride: {} },
deterministicSelection: {
status: "selected",
nodeId: candidateNodeId,
reason:
"Selected this tied candidate for deterministic decomposition-based reselection.",
},
});
if (!candidateResult.success) {
continue;
}
const nextGraph = candidateResult.updatedSituationGraph;
nextGraph.reasoningState = buildReasoningState(nextGraph);
const nextSelection = isSelectableUnresolvedUnknown(
nextGraph,
candidateResult.selectedChildNodeId,
)
? {
status: "selected",
nodeId: candidateResult.selectedChildNodeId,
reason:
"Selected the preserved decomposition child after resolving an initial tie.",
}
: candidateResult.deterministicSelection;
const questionResult = buildSelectedQuestionResult({
updatedSituationGraph: nextGraph,
deterministicSelection: nextSelection,
});
if (questionResult.selectedQuestion?.question) {
nextGraph.activeUnknownNodeId =
nextSelection?.status === "selected" ? nextSelection.nodeId : null;
nextGraph.currentSummary = describeGraph(nextGraph);
return {
...candidateResult,
updatedSituationGraph: nextGraph,
deterministicSelection: nextSelection,
...questionResult,
};
}
}
return null;
}
function reseatSelectionAfterQuestionRejection({
graph,
deterministicSelection,
activePattern = null,
excludedNodeIds = [],
}) {
const nextSelection = activePattern
? selectPatternCompatibleUnknownCandidate({
graph,
resolvedNodeIds: graph.resolvedNodeIds || [],
activePattern,
excludedNodeIds,
})
: selectActiveUnknownCandidate(graph, [
...(graph.resolvedNodeIds || []),
...excludedNodeIds,
]);
return nextSelection?.status
? nextSelection
: { status: "none", nodeId: null };
}
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);
let questionResult = buildSelectedQuestionResult({
updatedSituationGraph,
deterministicSelection,
});
const initialQuestionRejected =
questionResult.selectedQuestion?.question &&
questionResult.selectedQuestion?.questionComplexity &&
questionResult.selectedQuestion.questionComplexity.acceptable === false;
if (
initialQuestionRejected &&
deterministicSelection?.status === "selected"
) {
deterministicSelection = reseatSelectionAfterQuestionRejection({
graph: updatedSituationGraph,
deterministicSelection,
excludedNodeIds: [deterministicSelection.nodeId],
});
questionResult = buildSelectedQuestionResult({
updatedSituationGraph,
deterministicSelection,
});
}
if (
deterministicSelection?.status === "ambiguous" &&
!questionResult.selectedQuestion?.question
) {
const reselectionResult = resolveAmbiguousGraphBackedSelection({
graphSnapshot,
updatedSituationGraph,
deterministicSelection,
});
if (reselectionResult) {
updatedSituationGraph = reselectionResult.updatedSituationGraph;
deterministicSelection = reselectionResult.deterministicSelection;
questionResult = reselectionResult;
}
}
const noQuestionReason = questionResult.selectedQuestion?.question
? null
: deterministicSelection?.status === "ambiguous"
? questionResult.selectedQuestion?.questionSuppressedReason ||
questionResult.selectedQuestion?.reason ||
"Eligible unresolved candidates remain tied after initial graph-backed selection."
: (updatedSituationGraph.nodes || []).some(
(node) =>
node.kind === "unknown" &&
!["resolved", "contradicted"].includes(node.status) &&
!(updatedSituationGraph.resolvedNodeIds || []).includes(node.id),
)
? "Compatible unresolved candidates remain, but none produced a valid graph-backed question."
: "No unresolved unknown candidates remain after initial graph construction.";
return {
success: true,
updatedSituationGraph,
deterministicSelection,
selectedQuestion: questionResult.selectedQuestion,
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:
questionResult.formulatedQuestion?.questionComplexity ?? null,
noQuestionReason,
};
}
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;
let activeReasoningPattern = null;
let activeReasoningPatternReason = null;
let incompatibleNodeIds = [];
let compatibilityFailures = [];
let replacementActions = [];
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;
}
if (!activeReasoningPattern) {
const activePatternSelection = determineActiveReasoningPattern(
selectedNode,
workingGraph,
);
activeReasoningPattern = activePatternSelection.pattern;
activeReasoningPatternReason = activePatternSelection.reason;
}
const selectedNodeCompatibility = assessReasoningPatternCompatibility({
node: selectedNode,
graph: workingGraph,
activePattern: activeReasoningPattern,
});
if (!selectedNodeCompatibility.compatible) {
incompatibleNodeIds = appendUniqueValue(
incompatibleNodeIds,
selectedNode.id,
);
compatibilityFailures.push(
buildCompatibilityFailure(
selectedNode,
selectedNodeCompatibility,
"Selected unknown violated the active reasoning pattern.",
),
);
const replacementSelection = selectPatternCompatibleUnknownCandidate({
graph: workingGraph,
resolvedNodeIds: workingGraph.resolvedNodeIds,
activePattern: activeReasoningPattern,
excludedNodeIds: [selectedNode.id],
});
if (replacementSelection?.status === "selected") {
replacementActions.push({
rejectedNodeId: selectedNode.id,
replacementNodeId: replacementSelection.nodeId,
reason:
"Replaced an incompatible active unknown with the next pattern-compatible candidate.",
});
workingSelection = replacementSelection;
continue;
}
decompositionStoppedReason =
"No reasoning-pattern-compatible unknown remained available for selection.";
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,
activeReasoningPattern,
);
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,
activeReasoningPattern,
);
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,
activeReasoningPattern,
);
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,
activeReasoningPattern,
activeReasoningPatternReason,
incompatibleNodeIds,
compatibilityFailures,
replacementActions,
};
}
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 previousActiveUnknownNode = previousActiveUnknownNodeId
? findNodeById(graphSnapshot, previousActiveUnknownNodeId)
: 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;
const resolvedCurrentTurnNodeIds = [
...new Set(proposalSnapshot.resolvedUnknownNodeIds || []),
];
let postPropagationIncompatibleNodeIds = [];
let postPropagationCompatibilityFailures = [];
let postPropagationReplacementActions = [];
let activeUnknownIncompatibleNodeIds = [];
let activeUnknownCompatibilityFailures = [];
let activeUnknownReplacementActions = [];
const preservedSelectedChildNode = isSelectableUnresolvedUnknown(
updatedSituationGraph,
decompositionResult.selectedChildNodeId,
)
? findNodeById(
updatedSituationGraph,
decompositionResult.selectedChildNodeId,
)
: null;
const preservedSelectedChildCompatibility = preservedSelectedChildNode
? assessReasoningPatternCompatibility({
node: preservedSelectedChildNode,
graph: updatedSituationGraph,
activePattern: decompositionResult.activeReasoningPattern,
})
: null;
if (
preservedSelectedChildNode &&
preservedSelectedChildCompatibility?.compatible
) {
deterministicSelection = {
status: "selected",
nodeId: decompositionResult.selectedChildNodeId,
reason:
"Preserved the selected decomposition child because it remains unresolved and reasoning-pattern-compatible after propagation.",
};
} else {
if (preservedSelectedChildNode) {
const rejectionDiagnostics = buildRejectedSelectionDiagnostics({
node: preservedSelectedChildNode,
graph: updatedSituationGraph,
activePattern: decompositionResult.activeReasoningPattern,
reason:
"Preserved decomposition child violated the active reasoning pattern after propagation.",
});
postPropagationIncompatibleNodeIds =
rejectionDiagnostics.incompatibleNodeIds;
postPropagationCompatibilityFailures =
rejectionDiagnostics.compatibilityFailures;
}
deterministicSelection = selectPatternCompatibleUnknownCandidate({
graph: updatedSituationGraph,
resolvedNodeIds: updatedSituationGraph.resolvedNodeIds,
activePattern: decompositionResult.activeReasoningPattern,
excludedNodeIds: preservedSelectedChildNode
? [preservedSelectedChildNode.id]
: [],
});
if (
preservedSelectedChildNode &&
deterministicSelection?.status === "selected"
) {
postPropagationReplacementActions.push({
rejectedNodeId: preservedSelectedChildNode.id,
replacementNodeId: deterministicSelection.nodeId,
reason:
"Replaced a preserved decomposition child that violated reasoning-pattern consistency.",
});
}
}
if (deterministicSelection?.status === "ambiguous") {
const orderedSiblingSelection = selectOrderedSiblingCandidate(
updatedSituationGraph,
deterministicSelection.tiedCandidateIds || [],
resolvedCurrentTurnNodeIds,
);
if (orderedSiblingSelection) {
deterministicSelection = orderedSiblingSelection;
}
}
const carriedActiveUnknownNode = previousActiveUnknownNodeId
? findNodeById(updatedSituationGraph, previousActiveUnknownNodeId)
: null;
const carriedActiveUnknownStillUnresolved = Boolean(
carriedActiveUnknownNode &&
carriedActiveUnknownNode.kind === "unknown" &&
!["resolved", "contradicted"].includes(carriedActiveUnknownNode.status) &&
!updatedSituationGraph.resolvedNodeIds.includes(
carriedActiveUnknownNode.id,
),
);
if (
carriedActiveUnknownStillUnresolved &&
decompositionResult.activeReasoningPattern
) {
const carriedActiveCompatibility = assessReasoningPatternCompatibility({
node: carriedActiveUnknownNode,
graph: updatedSituationGraph,
activePattern: decompositionResult.activeReasoningPattern,
});
if (!carriedActiveCompatibility.compatible) {
activeUnknownIncompatibleNodeIds = [carriedActiveUnknownNode.id];
activeUnknownCompatibilityFailures = [
buildCompatibilityFailure(
carriedActiveUnknownNode,
carriedActiveCompatibility,
"Carried active unknown violated the active reasoning pattern and was not retained for investigation.",
),
];
if (
deterministicSelection?.status === "selected" &&
deterministicSelection.nodeId !== carriedActiveUnknownNode.id
) {
activeUnknownReplacementActions = [
{
rejectedNodeId: carriedActiveUnknownNode.id,
replacementNodeId: deterministicSelection.nodeId,
reason:
"Replaced an incompatible carried active unknown with a pattern-compatible investigation target.",
},
];
}
}
}
const unresolvedCandidates = listUnresolvedUnknownCandidates(
updatedSituationGraph,
resolvedCurrentTurnNodeIds,
);
const eligibleCandidatesBeforeCompatibility = listEligibleUnknownCandidates(
updatedSituationGraph,
resolvedCurrentTurnNodeIds,
);
const eligibleCandidates = eligibleCandidatesBeforeCompatibility.filter(
(node) =>
assessReasoningPatternCompatibility({
node,
graph: updatedSituationGraph,
activePattern: decompositionResult.activeReasoningPattern,
}).compatible,
);
const compatibilityDiagnostics = collectPatternCompatibilityDiagnostics({
graph: updatedSituationGraph,
activePattern: decompositionResult.activeReasoningPattern,
candidateNodeIds: eligibleCandidates.map((node) => node.id),
});
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;
} else if (eligibleCandidates.length > 0) {
const siblingFallbackNodeId =
nextSelectedSibling || eligibleCandidates[0]?.id || null;
if (siblingFallbackNodeId) {
deterministicSelection = {
status: "selected",
nodeId: siblingFallbackNodeId,
reason:
"Selected an eligible sibling after update-time validation left the original follow-up unavailable.",
};
newActiveUnknownNodeId = siblingFallbackNodeId;
} else {
newActiveUnknownNodeId = null;
}
} else {
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,
reasoningPattern: formulatedQuestion?.reasoningPattern,
reasoningPatternReason: formulatedQuestion?.reasoningPatternReason,
questionFamily: formulatedQuestion?.questionFamily,
allowedQuestionFamilies:
formulatedQuestion?.allowedQuestionFamilies,
rejectedQuestionFamilies:
formulatedQuestion?.rejectedQuestionFamilies,
selectedQuestionTemplate:
formulatedQuestion?.selectedQuestionTemplate,
questionComplexity,
plainLanguageNormalisations,
}
: null;
const repeatedQuestionRejected = Boolean(
finalSelectedQuestion?.question &&
deterministicSelection?.status === "selected" &&
isStructurallyRepeatedQuestion({
previousQuestion,
previousNode: previousActiveUnknownNode,
nextQuestion: finalSelectedQuestion.question,
nextNode: selectedNode,
nextQuestionFamily: finalSelectedQuestion.questionFamily,
nextInvestigationStrategy: finalSelectedQuestion.strategy,
}),
);
if (repeatedQuestionRejected) {
const repeatedSelection = reseatSelectionAfterQuestionRejection({
graph: updatedSituationGraph,
deterministicSelection,
activePattern: decompositionResult.activeReasoningPattern,
excludedNodeIds: [deterministicSelection.nodeId],
});
if (
repeatedSelection?.status === "selected" &&
repeatedSelection.nodeId !== deterministicSelection.nodeId
) {
deterministicSelection = repeatedSelection;
}
}
const selectedNodeAfterRepetitionCheck =
deterministicSelection?.status === "selected" &&
deterministicSelection?.nodeId
? updatedSituationGraph.nodes.find(
(node) => node.id === deterministicSelection.nodeId,
)
: null;
const formulatedQuestionAfterRepetitionCheck =
selectedNodeAfterRepetitionCheck
? formulateQuestion({
node: selectedNodeAfterRepetitionCheck,
graph: updatedSituationGraph,
context: {
resolvedValues: validatedProposal.updatedNodes
.map((update) => update.newValue)
.filter(
(value) => typeof value === "string" && value.trim().length > 0,
),
selectionState: deterministicSelection,
},
})
: null;
const effectiveFormulatedQuestion =
formulatedQuestionAfterRepetitionCheck || formulatedQuestion;
const effectiveSelectedNode =
selectedNodeAfterRepetitionCheck || selectedNode;
const effectiveQuestionComplexity =
effectiveFormulatedQuestion?.questionComplexity ?? null;
const effectivePlainLanguageNormalisations =
effectiveFormulatedQuestion?.plainLanguageNormalisations ?? [];
const effectiveSelectedQuestion =
deterministicSelection?.status === "ambiguous"
? {
nodeId: null,
tiedCandidateIds: deterministicSelection.tiedCandidateIds,
question: null,
reason: deterministicSelection.reason,
}
: deterministicSelection?.status === "selected"
? {
nodeId: deterministicSelection.nodeId,
question:
effectiveFormulatedQuestion?.question ||
deterministicSelection.question,
reason:
repeatedQuestionRejected &&
deterministicSelection?.nodeId !== previousActiveUnknownNodeId
? buildRepeatedQuestionDiagnostics(effectiveSelectedNode)
: effectiveFormulatedQuestion?.reason ||
deterministicSelection.reason,
strategy: effectiveFormulatedQuestion?.strategy,
investigationStrategy:
effectiveFormulatedQuestion?.investigationStrategy,
reasoningPattern: effectiveFormulatedQuestion?.reasoningPattern,
reasoningPatternReason:
effectiveFormulatedQuestion?.reasoningPatternReason,
questionFamily: effectiveFormulatedQuestion?.questionFamily,
allowedQuestionFamilies:
effectiveFormulatedQuestion?.allowedQuestionFamilies,
rejectedQuestionFamilies:
effectiveFormulatedQuestion?.rejectedQuestionFamilies,
selectedQuestionTemplate:
effectiveFormulatedQuestion?.selectedQuestionTemplate,
questionComplexity: effectiveQuestionComplexity,
plainLanguageNormalisations: effectivePlainLanguageNormalisations,
}
: null;
const finalSelectionCompatibility = effectiveSelectedQuestion?.nodeId
? assessReasoningPatternCompatibility({
node: findNodeById(
updatedSituationGraph,
effectiveSelectedQuestion.nodeId,
),
graph: updatedSituationGraph,
activePattern: decompositionResult.activeReasoningPattern,
})
: null;
const finalSelectedChildNodeId =
selectedChildNodeId ??
(selectedQuestionBelongsToChild(
updatedSituationGraph,
effectiveSelectedQuestion,
)
? effectiveSelectedQuestion?.nodeId
: null);
const noQuestionReason = effectiveSelectedQuestion?.question
? null
: deterministicSelection?.status === "ambiguous"
? "Eligible unresolved candidates remain tied after update-time reselection."
: eligibleCandidates.length === 0
? unresolvedCandidates.length === 0
? "No unresolved unknown candidates remain after this update."
: "Unresolved unknowns remain, but none are currently eligible for direct investigation."
: "Question formulation did not produce a valid next question despite eligible unresolved candidates.";
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 ||
(effectiveSelectedQuestion?.nodeId &&
!finalSelectionCompatibility?.compatible)
) {
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)`,
),
...(!finalSelectionCompatibility?.compatible &&
effectiveSelectedQuestion?.nodeId
? [
`Active unknown violates reasoning pattern consistency: "${effectiveSelectedQuestion.nodeId}" is ${finalSelectionCompatibility?.nodePattern} but active pattern is ${finalSelectionCompatibility?.activePattern}`,
]
: []),
],
};
}
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: effectiveQuestionComplexity?.acceptable ?? null,
primaryConceptCount:
effectiveQuestionComplexity?.primaryConceptCount ?? null,
cognitiveLoad: effectiveQuestionComplexity?.cognitiveLoad ?? null,
complexityReasons: effectiveQuestionComplexity?.reasons ?? [],
decompositionTriggeredByQuestionComplexity:
decompositionResult.decompositionTriggeredByQuestionComplexity ?? false,
decompositionTriggeredByAnswerability:
decompositionResult.decompositionTriggeredByAnswerability ?? false,
previousQuestion,
finalQuestion: effectiveSelectedQuestion?.question ?? null,
unresolvedCandidateCount: unresolvedCandidates.length,
eligibleCandidateCount: eligibleCandidates.length,
candidateNodeIds: eligibleCandidates.map((node) => node.id),
resolvedCurrentTurnNodeIds,
noQuestionReason,
plainLanguageNormalisations: effectivePlainLanguageNormalisations,
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,
reasoningPatternValidation:
compatibilityDiagnostics.reasoningPatternValidation,
patternCompatibleNodeCount:
compatibilityDiagnostics.patternCompatibleNodeCount,
incompatibleNodeIds: [
...new Set([
...(decompositionResult.incompatibleNodeIds || []),
...postPropagationIncompatibleNodeIds,
...activeUnknownIncompatibleNodeIds,
...compatibilityDiagnostics.incompatibleNodeIds,
]),
],
compatibilityFailures: [
...(decompositionResult.compatibilityFailures || []),
...postPropagationCompatibilityFailures,
...activeUnknownCompatibilityFailures,
...compatibilityDiagnostics.compatibilityFailures,
],
replacementActions: [
...(decompositionResult.replacementActions || []),
...postPropagationReplacementActions,
...activeUnknownReplacementActions,
],
graphReasoningIntegrity: compatibilityDiagnostics.graphReasoningIntegrity,
selectedChildUnknown:
finalSelectedChildNodeId ??
(deterministicSelection?.status === "selected"
? deterministicSelection.nodeId
: null),
atomicityReason:
propagationReason ||
decompositionReason ||
atomicityAssessment?.reason ||
null,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion: effectiveSelectedQuestion,
changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds),
graphReferenceValidation: resultReferenceValidation,
previousReasoningState: reasoningResolution.previousReasoningState,
reasoningState: nextReasoningState,
};
}