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

4521 lines
141 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describeGraph } from "./builder.js";
import {
assessUnknownAnswerability,
assessUnknownAtomicity,
buildReasoningState,
classifyObservationRelationship,
COMPARABILITY_REASONING_NODE_ID,
formulateQuestion,
formulateTieResolutionQuestion,
selectReasoningPattern,
} from "./question-formulator.js";
import {
answerResolutionGuidance,
answerSupportCategory,
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, answerMeaning) {
const errors = [];
const addedUnknowns = proposal.addedNodes.filter(
(node) => node.kind === "unknown",
);
const userSupportedMeaningText = answerMeaning?.userSupportedMeaning;
function hasNodeLevelUserSupport(unknownNode) {
if (!userSupportedMeaningText) return false;
const unknownText = normaliseSemanticText(
[normaliseText(unknownNode.label), normaliseText(unknownNode.description)]
.filter(Boolean)
.join(" "),
);
return rawAnswerSupportsUnclassifiedMeaning(
userSupportedMeaningText,
unknownText,
);
}
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 (
!hasNodeLevelUserSupport(unknownNode) &&
!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)
) {
nextProposal.resolvedUnknownNodeIds.push(update.nodeId);
}
}
if (nextProposal.selectedQuestion?.nodeId) {
const selectedQuestionNodeId = nextProposal.selectedQuestion.nodeId;
const selectedQuestionUpdate = updatedNodeById.get(selectedQuestionNodeId);
const selectedQuestionResolvedByStatus =
selectedQuestionUpdate?.newStatus === "resolved";
const selectedQuestionResolvedById = nextProposal.resolvedUnknownNodeIds.includes(
selectedQuestionNodeId,
);
if (selectedQuestionResolvedByStatus || selectedQuestionResolvedById) {
nextProposal.selectedQuestion = 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 parentSupportsComparisonDecomposition(parentText) {
return /\b(compare|comparison|comparable|comparability|different timing|timing|measured|measurement|basis|scale|same period|timing or measurement basis|two observations)\b/.test(
parentText,
);
}
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 (parentSupportsComparisonDecomposition(parentText)) {
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 [];
}
function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
const templates = buildDecompositionTemplates(parentNode, graph, depth);
if (templates.length === 0) {
return {
accepted: false,
childNodes: [],
childEdges: [],
childNodeIds: [],
proposedChildCount: 0,
acceptedChildCount: 0,
rejectedChildren: [],
childQualitySummary: [],
reason:
"Decomposition stopped because no meaning-preserving child family was justified for this parent.",
};
}
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" &&
!["known", "resolved", "contradicted"].includes(node.status) &&
!(graph.resolvedNodeIds || []).includes(node.id),
);
}
function listUnresolvedUnknownCandidates(
graph,
resolvedCurrentTurnNodeIds = [],
) {
const resolvedCurrentTurnSet = new Set(resolvedCurrentTurnNodeIds || []);
return (graph.nodes || []).filter(
(node) =>
isSelectableUnresolvedUnknown(graph, 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,
sourceNodeId: 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,
sourceNodeId: parentNode.id,
reason: `Inherited active reasoning pattern from parent node because ${parentSelection.reason}`,
};
}
currentParentId = parentNode.parentId;
}
const selection = selectReasoningPattern({ node, graph });
return {
pattern: selection.pattern,
sourceNodeId: node.id,
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;
}
// ── Structural embedding predicate (60B.16) ──────────────────
const STRUCTURAL_CONSEQUENCE_RELATIONSHIPS = ["may_cause", "causes", "affects"];
function checkRouteAEmbedding({
node,
graph,
activePattern,
activeNodeId,
nodePattern,
}) {
if (
activePattern !== "decision" ||
nodePattern !== "diagnosis" ||
!activeNodeId
) {
return false;
}
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
let current = node?.parentId ? nodesById.get(node.parentId) : null;
while (current) {
if (current.id === activeNodeId) {
return true;
}
current = current.parentId ? nodesById.get(current.parentId) : null;
}
return false;
}
function checkRouteBEmbedding({
node,
graph,
activePattern,
activeNodeId,
nodePattern,
}) {
if (
activePattern !== "decision" ||
nodePattern !== "diagnosis" ||
!activeNodeId
) {
return false;
}
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
const edges = graph.edges || [];
// Find candidate option Z: X --(may_cause/causes/affects)--> Z
let candidateOptionZ = null;
for (const edge of edges) {
if (
edge.fromNodeId === node.id &&
STRUCTURAL_CONSEQUENCE_RELATIONSHIPS.includes(edge.relationship)
) {
candidateOptionZ = nodesById.get(edge.toNodeId);
break;
}
}
if (!candidateOptionZ) {
return false;
}
// Z must be kind=option and contained_in active decision
if (candidateOptionZ.kind !== "option") {
return false;
}
// Check the contained_in edge from Z to a decision node that matches activeNodeId
for (const edge of edges) {
if (
edge.fromNodeId === candidateOptionZ.id &&
edge.relationship === "contained_in"
) {
const targetNode = nodesById.get(edge.toNodeId);
if (targetNode && targetNode.id === activeNodeId) {
return true;
}
// If contained_in points to a different decision, reject
}
}
return false;
}
export function assessReasoningPatternCompatibility({
node,
graph,
activePattern,
activeNodeId,
structurallyAdmittedNodeIds,
}) {
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);
const admittedSet = structurallyAdmittedNodeIds
? structurallyAdmittedNodeIds instanceof Set
? structurallyAdmittedNodeIds
: new Set(structurallyAdmittedNodeIds)
: null;
if (!compatible && admittedSet?.has(node.id)) {
return {
compatible: true,
activePattern,
nodePattern,
allowedPatterns,
structuralEmbedding: true,
reason:
"Node remains eligible because this same-turn unknown was admitted through bounded structural context fallback at the pre-mutation proposal boundary.",
};
}
return {
compatible,
activePattern,
nodePattern,
allowedPatterns,
structuralEmbedding: false,
reason: compatible
? `Node remains compatible because ${nodePattern} is allowed during ${activePattern} reasoning.`
: `Node is incompatible because ${nodePattern} is not allowed during ${activePattern} reasoning.`,
};
}
export function assessStructuralContextAdmission({
node,
graph,
activePattern,
activeNodeId,
}) {
const compatibility = assessReasoningPatternCompatibility({
node,
graph,
activePattern,
activeNodeId,
});
if (compatibility.compatible) {
return {
admitted: false,
intrinsicCompatible: true,
activePattern,
activeNodeId: activeNodeId ?? null,
nodePattern: compatibility.nodePattern,
routeA: false,
routeB: false,
structuralEmbedding: false,
reason: compatibility.reason,
};
}
let routeA = false;
let routeB = false;
try {
routeA = checkRouteAEmbedding({
node,
graph,
activePattern,
activeNodeId: activeNodeId ?? null,
nodePattern: compatibility.nodePattern,
});
} catch (_) {
// Non-fatal — bounded structural admission is optional.
}
try {
routeB = checkRouteBEmbedding({
node,
graph,
activePattern,
activeNodeId: activeNodeId ?? null,
nodePattern: compatibility.nodePattern,
});
} catch (_) {
// Non-fatal.
}
return {
admitted: routeA || routeB,
intrinsicCompatible: false,
activePattern,
activeNodeId: activeNodeId ?? null,
nodePattern: compatibility.nodePattern,
routeA,
routeB,
structuralEmbedding: routeA || routeB,
reason:
routeA || routeB
? `Node is structurally embedded in the original active decision context (intrinsic pattern ${compatibility.nodePattern} preserved).`
: compatibility.reason,
};
}
function collectStructurallyAdmittedUnknownNodeIds({ graph, proposal }) {
const activeNodeId = graph?.activeUnknownNodeId ?? null;
const activeNode = activeNodeId ? findNodeById(graph, activeNodeId) : null;
const activePattern = activeNode
? determineActiveReasoningPattern(activeNode, graph).pattern
: null;
if (activePattern !== "decision" || !activeNodeId) {
return new Set();
}
const proposalGraph = {
...graph,
nodes: [...(graph.nodes || []), ...(proposal?.addedNodes || [])],
edges: [...(graph.edges || []), ...(proposal?.addedEdges || [])],
};
return new Set(
(proposal?.addedNodes || [])
.filter((node) => node.kind === "unknown" && node.status !== "resolved")
.filter(
(node) =>
assessStructuralContextAdmission({
node,
graph: proposalGraph,
activePattern,
activeNodeId,
}).admitted,
)
.map((node) => node.id),
);
}
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,
structurallyAdmittedNodeIds,
}) {
const compatibility = assessReasoningPatternCompatibility({
node,
graph,
activePattern,
activeNodeId: graph.activeUnknownNodeId ?? null,
structurallyAdmittedNodeIds,
});
return {
incompatibleNodeIds: node?.id ? [node.id] : [],
compatibilityFailures: [
buildCompatibilityFailure(node, compatibility, reason),
],
};
}
function selectPatternCompatibleUnknownCandidate({
graph,
resolvedNodeIds = [],
activePattern,
excludedNodeIds = [],
structurallyAdmittedNodeIds,
}) {
if (!activePattern) {
return selectActiveUnknownCandidate(graph, resolvedNodeIds);
}
const excluded = new Set(excludedNodeIds || []);
const incompatibleNodeIds = (graph.nodes || [])
.filter(
(node) =>
node.kind === "unknown" &&
!excluded.has(node.id) &&
isSelectableUnresolvedUnknown(graph, node.id),
)
.filter(
(node) =>
!assessReasoningPatternCompatibility({
node,
graph,
activePattern,
activeNodeId: graph.activeUnknownNodeId ?? null,
structurallyAdmittedNodeIds,
}).compatible,
)
.map((node) => node.id);
return selectActiveUnknownCandidate(graph, [
...new Set([
...(resolvedNodeIds || []),
...incompatibleNodeIds,
...excludedNodeIds,
]),
]);
}
function hasUnresolvedSameProposalDependsOnPrerequisite({
graph,
proposal,
targetNodeId,
}) {
const addedNodeIds = new Set(
(proposal?.addedNodes || []).map((node) => node.id),
);
if (!addedNodeIds.has(targetNodeId)) return false;
const targetNode = findNodeById(graph, targetNodeId);
if (!targetNode) return false;
const directPrerequisiteIds = new Set();
for (const dependencyId of targetNode.dependsOn || []) {
directPrerequisiteIds.add(dependencyId);
}
for (const edge of graph.edges || []) {
if (
edge.relationship === "depends_on" &&
edge.fromNodeId === targetNodeId &&
edge.toNodeId
) {
directPrerequisiteIds.add(edge.toNodeId);
}
}
return [...directPrerequisiteIds].some(
(nodeId) =>
addedNodeIds.has(nodeId) && isSelectableUnresolvedUnknown(graph, nodeId),
);
}
function collectPatternCompatibilityDiagnostics({
graph,
activePattern,
candidateNodeIds = [],
structurallyAdmittedNodeIds,
}) {
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,
activeNodeId: graph.activeUnknownNodeId ?? null,
structurallyAdmittedNodeIds,
}),
}))
.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,
structurallyAdmittedNodeIds,
) {
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,
activeNodeId: graph.activeUnknownNodeId ?? null,
structurallyAdmittedNodeIds,
});
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 = [],
structurallyAdmittedNodeIds,
}) {
const nextSelection = activePattern
? selectPatternCompatibleUnknownCandidate({
graph,
resolvedNodeIds: graph.resolvedNodeIds || [],
activePattern,
excludedNodeIds,
structurallyAdmittedNodeIds,
})
: 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,
structurallyAdmittedNodeIds = new Set(),
}) {
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 activeReasoningContextNodeId = 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;
activeReasoningContextNodeId = activePatternSelection.sourceNodeId;
}
const selectedNodeCompatibility = assessReasoningPatternCompatibility({
node: selectedNode,
graph: workingGraph,
activePattern: activeReasoningPattern,
activeNodeId: activeReasoningContextNodeId,
structurallyAdmittedNodeIds,
});
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],
structurallyAdmittedNodeIds,
});
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,
structurallyAdmittedNodeIds,
);
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,
structurallyAdmittedNodeIds,
);
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,
activeReasoningContextNodeId,
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 normaliseSemanticText(value) {
return String(value || "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function semanticContentTokens(value) {
const stopWords = new Set([
"the",
"and",
"for",
"that",
"this",
"with",
"from",
"into",
"than",
"then",
"they",
"them",
"their",
"there",
"about",
"would",
"could",
"should",
"because",
"being",
"been",
"have",
"has",
"had",
"were",
"what",
"when",
"where",
"which",
"while",
"mainly",
"roughly",
"specifically",
"directly",
"user",
]);
return normaliseSemanticText(value)
.replace(/[^a-z0-9]+/g, " ")
.split(" ")
.filter((token) => token.length > 2 && !stopWords.has(token));
}
function semanticOverlapRatio(sourceText, candidateText) {
const source = new Set(semanticContentTokens(sourceText));
const candidate = new Set(semanticContentTokens(candidateText));
if (candidate.size === 0) return 1;
const overlap = [...candidate].filter((token) => source.has(token)).length;
return overlap / candidate.size;
}
function rawAnswerSupportsUnclassifiedMeaning(answer, userSupportedMeaning) {
const overlapRatio = semanticOverlapRatio(answer, userSupportedMeaning);
const overlappingTokens = semanticContentTokens(userSupportedMeaning).filter(
(token) => semanticContentTokens(answer).includes(token),
).length;
return overlapRatio >= 0.4 || overlappingTokens >= 3;
}
function hasConditionalQualification(text) {
const value = normaliseSemanticText(text);
return (
value.includes("might") ||
value.includes("normally") ||
value.includes("for the right opportunity") ||
value.includes("depends") ||
value.includes("conditional") ||
value.includes("under specific")
);
}
function containsConstraintBoundaryLanguage(text) {
const value = normaliseSemanticText(text);
return (
value.includes("hard constraint") ||
value.includes("constraint") ||
value.includes("non negotiable") ||
value.includes("non-negotiable") ||
value.includes("preference") ||
value.includes("trade off") ||
value.includes("trade-off")
);
}
function containsWeakeningOfHardConstraint(text) {
const value = normaliseSemanticText(text);
return (
value.includes("preference") ||
value.includes("trade off") ||
value.includes("trade-off") ||
value.includes("not a hard constraint") ||
value.includes("rather than a hard constraint")
);
}
function proposalResolutionSummary(proposal) {
const resolved =
proposal.resolvedUnknownNodeIds.length > 0 ||
proposal.updatedNodes.some((update) => update.newStatus === "resolved");
const proposalText = normaliseSemanticText(
[
...(proposal.updatedNodes || []).flatMap((update) => [
update.reason,
update.newValue,
]),
proposal.selectedQuestion?.question,
proposal.selectedQuestion?.reason,
]
.filter(Boolean)
.join(" "),
);
return { resolved, proposalText };
}
function resolvedProposalMeaningText(proposal) {
return normaliseSemanticText(
(proposal.updatedNodes || [])
.filter((update) => update.newStatus === "resolved")
.map((update) => update.newValue)
.filter((value) => typeof value === "string" && value.trim().length > 0)
.join(" "),
);
}
function mentionsHardConstraint(text) {
return (
text.includes("hard constraint") ||
text.includes("non-negotiable") ||
text.includes("dont want any increase in risk") ||
text.includes("do not want any increase in risk")
);
}
function mentionsNegatedHardConstraint(text) {
return (
text.includes("rather than a hard constraint") ||
text.includes("not a hard constraint") ||
text.includes("not an absolute constraint") ||
text.includes("preference rather than a hard constraint")
);
}
function hasDefaultPreferenceSignal(text) {
return (
text.includes("preference") ||
text.includes("normally") ||
text.includes("default preference") ||
text.includes("would usually") ||
text.includes("tend to")
);
}
function hasExceptionOrOverrideSignal(text) {
return (
text.includes(" but ") ||
text.includes(" if ") ||
text.includes("override") ||
text.includes("overridden") ||
text.includes("willing to accept") ||
text.includes("willingness to accept") ||
text.includes("accept some risk")
);
}
function deriveAnswerMeaningProfile(userSupportedMeaning) {
const meaningText = normaliseSemanticText(userSupportedMeaning);
if (
meaningText.includes("not really sure") ||
meaningText.includes("not sure") ||
meaningText.includes("unsure") ||
meaningText.includes("do not know") ||
meaningText.includes("don't know")
) {
return {
category: "uncertain",
resolutionGuidance: "must_remain_unresolved",
};
}
const negatedHardConstraint = mentionsNegatedHardConstraint(meaningText);
const affirmativeHardConstraint =
mentionsHardConstraint(meaningText) && !negatedHardConstraint;
const conditionalPreferenceStructure =
(hasDefaultPreferenceSignal(meaningText) &&
hasExceptionOrOverrideSignal(meaningText)) ||
(negatedHardConstraint && hasExceptionOrOverrideSignal(meaningText)) ||
hasConditionalQualification(meaningText);
if (conditionalPreferenceStructure) {
return {
category: "conditional_tradeoff",
resolutionGuidance: "may_resolve",
};
}
if (affirmativeHardConstraint) {
return {
category: "explicit_hard_constraint",
resolutionGuidance: "must_resolve",
};
}
if (
meaningText.includes("matters more") ||
meaningText.includes("more important") ||
meaningText.includes("higher priority") ||
meaningText.includes("greater relative importance") ||
meaningText.includes("relative importance")
) {
return {
category: "relative_priority_only",
resolutionGuidance: "must_remain_unresolved",
};
}
return {
category: "other",
resolutionGuidance: null,
};
}
function getAnswerMeaningProfile(answerMeaning) {
if (!answerMeaning?.userSupportedMeaning) {
return {
category: null,
resolutionGuidance: null,
usedStructuredSupportCategory: false,
usedStructuredResolutionGuidance: false,
};
}
const derivedProfile = deriveAnswerMeaningProfile(
answerMeaning.userSupportedMeaning,
);
return {
category: answerMeaning.supportCategory ?? derivedProfile.category,
resolutionGuidance:
answerMeaning.resolutionGuidance ?? derivedProfile.resolutionGuidance,
usedStructuredSupportCategory: answerMeaning.supportCategory != null,
usedStructuredResolutionGuidance: answerMeaning.resolutionGuidance != null,
};
}
function validateAnswerMeaningCompatibilityWithRawAnswer({ answer, proposal }) {
if (!answer || !proposal.answerMeaning) return [];
if (
proposal.answerMeaning.supportCategory != null ||
proposal.answerMeaning.resolutionGuidance != null
) {
return [];
}
const errors = [];
const rawAnswerProfile = deriveAnswerMeaningProfile(answer);
const supportedMeaningProfile = getAnswerMeaningProfile(
proposal.answerMeaning,
);
const supportedMeaningText = normaliseSemanticText(
proposal.answerMeaning.userSupportedMeaning,
);
if (rawAnswerProfile.category === "relative_priority_only") {
if (supportedMeaningProfile.category !== "relative_priority_only") {
errors.push(
"answerMeaning.userSupportedMeaning introduces a stronger reasoning category than the raw answer establishes.",
);
}
if (containsConstraintBoundaryLanguage(supportedMeaningText)) {
errors.push(
"answerMeaning.userSupportedMeaning introduces an unsupported constraint or preference/trade-off distinction not present in the raw answer.",
);
}
}
if (rawAnswerProfile.category === "conditional_tradeoff") {
if (supportedMeaningProfile.category !== "conditional_tradeoff") {
errors.push(
"answerMeaning.userSupportedMeaning loses the raw answer's conditional trade-off structure.",
);
}
}
if (rawAnswerProfile.category === "uncertain") {
if (supportedMeaningProfile.category !== "uncertain") {
errors.push(
"answerMeaning.userSupportedMeaning overstates a raw answer that remains uncertain.",
);
}
}
if (rawAnswerProfile.category === "explicit_hard_constraint") {
if (supportedMeaningProfile.category !== "explicit_hard_constraint") {
errors.push(
"answerMeaning.userSupportedMeaning weakens a raw answer that explicitly states a hard constraint.",
);
}
}
if (rawAnswerProfile.category === "other") {
if (supportedMeaningProfile.category !== "other") {
errors.push(
"answerMeaning.userSupportedMeaning introduces a stronger reasoning category than the raw answer establishes.",
);
} else if (
!rawAnswerSupportsUnclassifiedMeaning(
answer,
proposal.answerMeaning.userSupportedMeaning,
)
) {
errors.push(
"answerMeaning.userSupportedMeaning introduces unsupported meaning beyond what the raw answer itself states.",
);
}
}
return errors;
}
function validateAnswerMeaningAlignment(proposal) {
if (!proposal.answerMeaning) return [];
const errors = [];
const { userSupportedMeaning } = proposal.answerMeaning;
const meaningText = normaliseSemanticText(userSupportedMeaning);
const { resolved, proposalText } = proposalResolutionSummary(proposal);
const profile = getAnswerMeaningProfile(proposal.answerMeaning);
const supportCategory = profile.category;
const resolutionGuidance = profile.resolutionGuidance;
const usesStructuredPath =
profile.usedStructuredSupportCategory ||
profile.usedStructuredResolutionGuidance;
if (usesStructuredPath) {
if (
resolutionGuidance === answerResolutionGuidance.must_remain_unresolved &&
resolved
) {
errors.push(
"Proposal resolves an unknown even though answerMeaning.resolutionGuidance is must_remain_unresolved.",
);
}
if (
supportCategory === answerSupportCategory.explicit_hard_constraint &&
resolutionGuidance === answerResolutionGuidance.must_resolve
) {
// Deferred: the current proposal structure does not safely identify which
// specific answered/targeted unknown must resolve in every case.
}
return errors;
}
if (resolutionGuidance === "must_remain_unresolved" && resolved) {
errors.push(
"Proposal resolves an unknown even though answerMeaning says the user's answer must remain unresolved.",
);
}
if (supportCategory === "relative_priority_only") {
if (containsConstraintBoundaryLanguage(meaningText)) {
errors.push(
"answerMeaning.userSupportedMeaning for relative_priority_only must not introduce a constraint or preference/trade-off judgement.",
);
}
if (containsConstraintBoundaryLanguage(proposalText)) {
errors.push(
"Proposal introduces unsupported constraint-boundary interpretation from a relative-priority answer.",
);
}
}
if (supportCategory === "conditional_tradeoff") {
if (!hasConditionalQualification(meaningText)) {
errors.push(
"answerMeaning.userSupportedMeaning for conditional_tradeoff must preserve the user's qualification or condition.",
);
}
if (resolved && !hasConditionalQualification(proposalText)) {
errors.push(
"Proposal resolves a conditional trade-off without preserving its conditional qualification in the proposed change.",
);
}
}
if (supportCategory === "uncertain") {
if (containsConstraintBoundaryLanguage(proposalText)) {
errors.push(
"Proposal introduces a stronger interpretation even though answerMeaning marks the answer as uncertain.",
);
}
}
if (supportCategory === "explicit_hard_constraint") {
if (!resolved && resolutionGuidance === "must_resolve") {
errors.push(
"Proposal leaves an explicitly stated hard constraint unresolved.",
);
}
if (containsWeakeningOfHardConstraint(proposalText)) {
errors.push(
"Proposal weakens an explicitly stated hard constraint into a preference or trade-off.",
);
}
}
if (supportCategory === "other") {
const resolvedMeaningText = resolvedProposalMeaningText(proposal);
if (
resolved &&
resolvedMeaningText &&
!rawAnswerSupportsUnclassifiedMeaning(
proposal.answerMeaning.userSupportedMeaning,
resolvedMeaningText,
)
) {
errors.push(
"Proposal cannot resolve beyond an unclassified answer by introducing unsupported stronger meaning than answerMeaning.userSupportedMeaning establishes.",
);
}
}
return errors;
}
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,
validatedProposal.answerMeaning,
),
);
const selectedQuestionValidation = validateSelectedQuestion(
situationGraph,
validatedProposal,
);
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
proposalCompatibilityErrors.push(
...validateAnswerMeaningCompatibilityWithRawAnswer({
answer,
proposal: validatedProposal,
}),
);
proposalCompatibilityErrors.push(
...validateAnswerMeaningAlignment(validatedProposal),
);
proposalCompatibilityErrors.push(
...validateQuestionSelectionRequirement(situationGraph, validatedProposal),
);
if (proposalCompatibilityErrors.length > 0) {
return {
success: false,
stage: "proposal_compatibility",
errors: proposalCompatibilityErrors,
};
}
const structurallyAdmittedNodeIds = collectStructurallyAdmittedUnknownNodeIds(
{
graph: situationGraph,
proposal: validatedProposal,
},
);
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 &&
isSelectableUnresolvedUnknown(
updatedSituationGraph,
newActiveUnknownNodeId,
);
if (!remainingUnknownExists) {
newActiveUnknownNodeId =
selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
)?.nodeId ?? null;
}
let deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
const decompositionResult = runDeterministicDecomposition({
graphSnapshot,
proposalSnapshot,
updatedSituationGraph,
reasoningResolution,
deterministicSelection,
structurallyAdmittedNodeIds,
});
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,
activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null,
structurallyAdmittedNodeIds,
})
: 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.",
structurallyAdmittedNodeIds,
});
postPropagationIncompatibleNodeIds =
rejectionDiagnostics.incompatibleNodeIds;
postPropagationCompatibilityFailures =
rejectionDiagnostics.compatibilityFailures;
}
deterministicSelection = selectPatternCompatibleUnknownCandidate({
graph: updatedSituationGraph,
resolvedNodeIds: updatedSituationGraph.resolvedNodeIds,
activePattern: decompositionResult.activeReasoningPattern,
excludedNodeIds: preservedSelectedChildNode
? [preservedSelectedChildNode.id]
: [],
structurallyAdmittedNodeIds,
});
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 &&
isSelectableUnresolvedUnknown(
updatedSituationGraph,
carriedActiveUnknownNode.id,
),
);
if (
carriedActiveUnknownStillUnresolved &&
decompositionResult.activeReasoningPattern
) {
const carriedActiveCompatibility = assessReasoningPatternCompatibility({
node: carriedActiveUnknownNode,
graph: updatedSituationGraph,
activePattern: decompositionResult.activeReasoningPattern,
activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null,
structurallyAdmittedNodeIds,
});
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,
activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null,
structurallyAdmittedNodeIds,
}).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;
// Bounded model-selection honour: prefer a model-selected target only when it
// is still valid, was added in this proposal turn, and has no unresolved
// same-proposal-added unknown prerequisite via depends_on. Otherwise fall
// through to existing deterministic selection unchanged.
if (validatedProposal.selectedQuestion?.nodeId) {
const candidateNodeId = validatedProposal.selectedQuestion.nodeId;
const candidateWasAddedThisProposal = (
validatedProposal.addedNodes || []
).some((node) => node.id === candidateNodeId);
if (
isSelectableUnresolvedUnknown(updatedSituationGraph, candidateNodeId) &&
candidateWasAddedThisProposal &&
!hasUnresolvedSameProposalDependsOnPrerequisite({
graph: updatedSituationGraph,
proposal: validatedProposal,
targetNodeId: candidateNodeId,
})
) {
deterministicSelection = {
status: "selected",
nodeId: candidateNodeId,
reason:
"Honoured the model-selected unresolved unknown as preferred target because it was added in this proposal and has no unresolved same-proposal depends_on prerequisite.",
};
}
}
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],
structurallyAdmittedNodeIds,
});
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,
activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null,
structurallyAdmittedNodeIds,
})
: 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,
};
}
// ── 60B.61 — decision remaining-material-factor detection ──
const TERMINAL_STATUSES = ["known", "resolved", "contradicted"];
function isUnresolvedUnknown(node) {
return (
node.kind === "unknown" && !TERMINAL_STATUSES.includes(node.status)
);
}
export function hasRemainingMaterialFactors(decisionNodeId, graph) {
return countRemainingMaterialFactors(decisionNodeId, graph) > 0;
}
// ── 60B.61 — count remaining material factors for a decision node ──
/**
* Count unresolved unknown nodes that remain material to a decision
* after all proposal updates have been applied.
*
* Routes (mirrors hasRemainingMaterialFactors but returns count):
* A: hierarchy unresolved unknown is ancestor/descendant of decision via parentId / childIds
* B: direct dep unresolved unknown depends_on the decision node
* C: consequence unresolved unknown affects/may_cause/causes an option contained in the decision
* D: containment unresolved unknown ->[contained_in]-> option ->[contained_in]-> decision
*/
export function countRemainingMaterialFactors(decisionNodeId, graph) {
const nodes = graph.nodes || [];
const edges = graph.edges || [];
const nodesById = new Map(nodes.map((n) => [n.id, n]));
const decisionNode = nodesById.get(decisionNodeId);
if (!decisionNode) return 0;
// Collect all option IDs that belong to this decision via contained_in
const decisionOptionIds = new Set();
for (const edge of edges) {
if (
edge.relationship === "contained_in" &&
edge.toNodeId === decisionNodeId
) {
decisionOptionIds.add(edge.fromNodeId);
}
}
// Build parentId upward chain for Route A
function getAncestorNode(nodeId, depth = 0) {
if (depth > 50) return null;
const n = nodesById.get(nodeId);
if (!n?.parentId) return null;
return nodesById.get(n.parentId) ?? null;
}
// All material factor node IDs (deduplicated)
const materialFactorIds = new Set();
// Route A: hierarchy (parentId chain reaches the decision — unknown is descendant)
for (const node of nodes) {
if (node.id === decisionNodeId || !isUnresolvedUnknown(node)) continue;
let currentParent = getAncestorNode(node.id);
while (currentParent) {
if (currentParent.id === decisionNodeId) {
materialFactorIds.add(node.id);
break;
}
currentParent = getAncestorNode(currentParent.id);
}
}
// Route A (cont.): direct childIds membership
for (const childId of decisionNode.childIds || []) {
const childNode = nodesById.get(childId);
if (childNode && isUnresolvedUnknown(childNode)) {
materialFactorIds.add(childId);
}
}
// Route B: direct dependency edge TO the decision
for (const edge of edges) {
if (edge.toNodeId !== decisionNodeId || edge.relationship !== "depends_on") continue;
const source = nodesById.get(edge.fromNodeId);
if (source && isUnresolvedUnknown(source)) {
materialFactorIds.add(edge.fromNodeId);
}
}
// Route C: consequence edge to option → contained_in → decision
for (const edge of edges) {
if (edge.relationship !== "affects" && edge.relationship !== "may_cause" && edge.relationship !== "causes") continue;
const source = nodesById.get(edge.fromNodeId);
const targetOptionId = edge.toNodeId;
if (!source || !isUnresolvedUnknown(source) || !decisionOptionIds.has(targetOptionId)) continue;
materialFactorIds.add(edge.fromNodeId);
}
// Route D: containment path — unknown ->[contained_in]-> option ->[contained_in]-> decision
for (const edge of edges) {
if (edge.relationship !== "contained_in") continue;
const fromNode = nodesById.get(edge.fromNodeId);
if (!fromNode || !isUnresolvedUnknown(fromNode)) continue;
// Check if the target is an option contained in the decision
for (const innerEdge of edges) {
if (
innerEdge.relationship === "contained_in" &&
innerEdge.fromNodeId === edge.toNodeId &&
innerEdge.toNodeId === decisionNodeId
) {
materialFactorIds.add(edge.fromNodeId);
break;
}
}
}
return materialFactorIds.size;
}