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

1917 lines
57 KiB
JavaScript

import { describeGraph } from "./builder.js";
import {
assessUnknownAtomicity,
buildReasoningState,
classifyObservationRelationship,
COMPARABILITY_REASONING_NODE_ID,
formulateQuestion,
} from "./question-formulator.js";
import {
graphUpdateSchema,
makeNodeId,
situationGraphSchema,
} from "./schema.js";
import {
applyGraphUpdate,
detectDuplicateNodeIds,
findAffectedNodes,
scoreUnknownCandidate,
selectActiveUnknownCandidate,
validateGraphReferences,
validateGraphUpdate,
} from "./utils.js";
function cloneJsonSafe(value) {
return JSON.parse(JSON.stringify(value));
}
function zodIssuesToErrors(error) {
return (
error?.issues?.map((issue) => {
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
return `${path}${issue.message}`;
}) ?? ["Validation failed"]
);
}
function collectDuplicateEdgeIds(edges) {
const counts = new Map();
for (const edge of edges) {
counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1);
}
return [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([edgeId, count]) => ({ edgeId, count }));
}
function normaliseText(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function buildNodeById(graph, addedNodes = []) {
return new Map(
[...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
);
}
function isCompoundQuestion(question) {
if (typeof question !== "string") return false;
const trimmed = question.trim();
if (!trimmed) return false;
const questionMarks = (trimmed.match(/\?/g) || []).length;
if (questionMarks > 1) return true;
if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed))
return true;
return false;
}
function validateAddedUnknowns(graph, proposal) {
const errors = [];
const addedUnknowns = proposal.addedNodes.filter(
(node) => node.kind === "unknown",
);
if (addedUnknowns.length > 3) {
errors.push(
`Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`,
);
}
const unresolvedExistingUnknowns = graph.nodes.filter(
(node) =>
node.kind === "unknown" &&
!proposal.resolvedUnknownNodeIds.includes(node.id),
);
const seenAddedUnknownMeanings = new Map();
const answerDerivedNodeIds = new Set([
...proposal.updatedNodes.map((update) => update.nodeId),
...proposal.resolvedUnknownNodeIds,
...proposal.addedNodes
.filter((node) => node.kind !== "unknown")
.map((node) => node.id),
]);
const proposalNodeById = buildNodeById(graph, proposal.addedNodes);
function hasExplicitNodeReference(fromNode, toNodeId) {
if (!fromNode || !toNodeId) return false;
return (
fromNode.parentId === toNodeId ||
fromNode.dependsOn.includes(toNodeId) ||
fromNode.affects.includes(toNodeId) ||
fromNode.childIds.includes(toNodeId)
);
}
function hasExplicitAnswerDerivedRelationship(unknownNode) {
const connectedEdge = proposal.addedEdges.find(
(edge) =>
(edge.fromNodeId === unknownNode.id &&
answerDerivedNodeIds.has(edge.toNodeId)) ||
(edge.toNodeId === unknownNode.id &&
answerDerivedNodeIds.has(edge.fromNodeId)),
);
if (connectedEdge) {
return true;
}
for (const answerDerivedNodeId of answerDerivedNodeIds) {
const answerDerivedNode = proposalNodeById.get(answerDerivedNodeId);
if (
hasExplicitNodeReference(unknownNode, answerDerivedNodeId) ||
hasExplicitNodeReference(answerDerivedNode, unknownNode.id)
) {
return true;
}
}
return false;
}
for (const unknownNode of addedUnknowns) {
const meaningKeys = [
normaliseText(unknownNode.label),
normaliseText(unknownNode.description),
].filter(Boolean);
for (const meaningKey of meaningKeys) {
if (seenAddedUnknownMeanings.has(meaningKey)) {
errors.push(
`Proposal adds duplicate unknown meaning: "${unknownNode.label}"`,
);
break;
}
seenAddedUnknownMeanings.set(meaningKey, unknownNode.id);
}
for (const existingUnknown of unresolvedExistingUnknowns) {
const existingMeaningKeys = [
normaliseText(existingUnknown.label),
normaliseText(existingUnknown.description),
].filter(Boolean);
if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) {
errors.push(
`Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`,
);
break;
}
}
if (
unknownNode.description.trim() === unknownNode.label.trim() ||
!/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test(
unknownNode.description,
)
) {
errors.push(
`New unknown must include why it matters in its description: "${unknownNode.id}"`,
);
}
if (!hasExplicitAnswerDerivedRelationship(unknownNode)) {
errors.push(
`New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`,
);
}
}
return errors;
}
function validateSelectedQuestion(graph, proposal) {
const errors = [];
const selectedQuestion = proposal.selectedQuestion;
const nodeById = buildNodeById(graph, proposal.addedNodes);
if (selectedQuestion == null) {
return { errors, selectedQuestionNodeId: null };
}
const node = nodeById.get(selectedQuestion.nodeId);
if (!node) {
errors.push(
`selectedQuestion references missing node: "${selectedQuestion.nodeId}"`,
);
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
}
if (node.kind !== "unknown") {
errors.push(
`selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`,
);
}
const resolvesNode = proposal.resolvedUnknownNodeIds.includes(
selectedQuestion.nodeId,
);
const updatedStatus = proposal.updatedNodes.find(
(update) => update.nodeId === selectedQuestion.nodeId,
)?.newStatus;
const effectiveStatus = updatedStatus ?? node.status;
if (resolvesNode || effectiveStatus === "resolved") {
errors.push(
`selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`,
);
}
if (
graph.activeUnknownNodeId &&
proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) &&
selectedQuestion.nodeId === graph.activeUnknownNodeId
) {
errors.push(
`selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`,
);
}
if (isCompoundQuestion(selectedQuestion.question)) {
errors.push("selectedQuestion must be a single non-compound question");
}
const resolvedNodeIds = [
...(graph.resolvedNodeIds || []),
...(proposal.resolvedUnknownNodeIds || []),
];
const candidateScore = scoreUnknownCandidate(
{
...graph,
nodes: [...graph.nodes, ...(proposal.addedNodes || [])],
edges: [...graph.edges, ...(proposal.addedEdges || [])],
},
node,
resolvedNodeIds,
);
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
}
function validateQuestionSelectionRequirement(graph, proposal) {
const addedConsequentialUnknowns = proposal.addedNodes.filter(
(node) => node.kind === "unknown" && node.status !== "resolved",
);
if (
proposal.selectedQuestion == null &&
addedConsequentialUnknowns.length > 0
) {
return [
"selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown",
];
}
return [];
}
function buildResolvedUnknownUpdate(node) {
return {
nodeId: node.id,
previousStatus: node.status ?? null,
newStatus: "resolved",
previousValue: node.value ?? null,
newValue: node.value ?? null,
reason:
"Resolved because the proposal explicitly marked this unknown as resolved.",
};
}
function reconcileResolutionSemantics(graph, proposal) {
const nextProposal = cloneJsonSafe(proposal);
const errors = [];
const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
const updatedNodeById = new Map(
nextProposal.updatedNodes.map((nodeUpdate) => [
nodeUpdate.nodeId,
nodeUpdate,
]),
);
for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) {
const existingNode = graphNodeById.get(resolvedUnknownNodeId);
if (!existingNode) {
errors.push(
`Resolved unknown must reference an existing node: "${resolvedUnknownNodeId}"`,
);
continue;
}
if (existingNode.kind !== "unknown") {
errors.push(
`Resolved unknown must reference an existing unknown node: "${resolvedUnknownNodeId}"`,
);
continue;
}
const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId);
if (!existingUpdate) {
const syntheticUpdate = buildResolvedUnknownUpdate(existingNode);
nextProposal.updatedNodes.push(syntheticUpdate);
updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate);
continue;
}
if (existingUpdate.newStatus !== "resolved") {
existingUpdate.newStatus = "resolved";
if (existingUpdate.previousStatus == null) {
existingUpdate.previousStatus = existingNode.status ?? null;
}
if (existingUpdate.previousValue === undefined) {
existingUpdate.previousValue = existingNode.value ?? null;
}
}
}
for (const update of nextProposal.updatedNodes) {
const existingNode = graphNodeById.get(update.nodeId);
if (
existingNode?.kind === "unknown" &&
update.newStatus === "resolved" &&
!nextProposal.resolvedUnknownNodeIds.includes(update.nodeId)
) {
errors.push(
`Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`,
);
}
}
return {
proposal: nextProposal,
errors,
};
}
function validateSemanticDuplicateUnknowns(graph, proposal) {
const errors = [];
const unresolvedUnknowns = graph.nodes.filter(
(node) =>
node.kind === "unknown" &&
!proposal.resolvedUnknownNodeIds.includes(node.id),
);
for (const addedNode of proposal.addedNodes) {
const addedTexts = [
normaliseText(addedNode.label),
normaliseText(addedNode.description),
].filter(Boolean);
for (const unresolvedUnknown of unresolvedUnknowns) {
const unresolvedTexts = [
normaliseText(unresolvedUnknown.label),
normaliseText(unresolvedUnknown.description),
].filter(Boolean);
const duplicatesMeaning = addedTexts.some((text) =>
unresolvedTexts.includes(text),
);
if (!duplicatesMeaning) continue;
const linkedToUnknown = proposal.addedEdges.some(
(edge) =>
(edge.fromNodeId === addedNode.id &&
edge.toNodeId === unresolvedUnknown.id) ||
(edge.toNodeId === addedNode.id &&
edge.fromNodeId === unresolvedUnknown.id),
);
const updatedUnknown = proposal.updatedNodes.some(
(update) => update.nodeId === unresolvedUnknown.id,
);
if (!linkedToUnknown && !updatedUnknown) {
errors.push(
`Proposal adds a node duplicating unresolved unknown meaning without linking or resolving it: "${unresolvedUnknown.id}"`,
);
}
}
}
return errors;
}
function buildAffectedNodeIds(graph, proposal) {
const affected = new Set(proposal.affectedNodeIds ?? []);
for (const update of proposal.updatedNodes ?? []) {
affected.add(update.nodeId);
for (const nodeId of findAffectedNodes(graph, update.nodeId)) {
affected.add(nodeId);
}
}
for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) {
affected.add(nodeId);
for (const affectedNodeId of findAffectedNodes(graph, nodeId)) {
affected.add(affectedNodeId);
}
}
return [...affected];
}
function buildChangesApplied(proposal, affectedNodeIds) {
return {
addedNodeCount: proposal.addedNodes.length,
addedUnknownCount: proposal.addedNodes.filter(
(node) => node.kind === "unknown",
).length,
updatedNodeCount: proposal.updatedNodes.length,
addedEdgeCount: proposal.addedEdges.length,
removedEdgeCount: proposal.removedEdgeIds.length,
resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length,
affectedNodeCount: affectedNodeIds.length,
};
}
function appendUniqueValue(values = [], nextValue) {
return nextValue && !values.includes(nextValue)
? [...values, nextValue]
: values;
}
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);
}
}
for (const edge of graph.edges || []) {
if (
edge.fromNodeId !== parentNode.id &&
edge.toNodeId === parentNode.id &&
edge.relationship === "depends_on"
) {
queue.push(edge.fromNodeId);
}
}
}
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 totalChildren = childUnknowns.length;
if (totalChildren === 0) {
return {
totalChildren,
resolvedChildren,
progressedChildren,
nextStatus: parentNode.status,
nextConfidence: parentNode.confidence,
parentResolved: parentNode.status === "resolved",
reason: "Parent has no child unknowns to aggregate.",
};
}
if (resolvedChildren.length === totalChildren) {
return {
totalChildren,
resolvedChildren,
progressedChildren,
nextStatus: "resolved",
nextConfidence: "high",
parentResolved: true,
reason:
"All direct child unknowns are resolved, so the parent can now resolve deterministically.",
};
}
if (progressedChildren.length > 0) {
return {
totalChildren,
resolvedChildren,
progressedChildren,
nextStatus: "provisional",
nextConfidence: "high",
parentResolved: false,
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,
nextStatus: parentNode.status,
nextConfidence: parentNode.confidence,
parentResolved: parentNode.status === "resolved",
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,
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();
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 progressState = computeParentProgressState(graph, ancestorNode);
ancestorNode.status = progressState.nextStatus;
ancestorNode.confidence = progressState.nextConfidence;
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,
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,
affectedAncestorIds: [...affectedAncestorIds],
nextSelectedSibling:
siblingSelection?.status === "selected" ? siblingSelection.nodeId : null,
parentResolved: firstEvent?.parentResolved ?? false,
reason:
firstEvent?.reason ??
"Resolved child evidence propagated upward through the decomposition chain.",
};
}
function buildEmergentReasoningUnknownLabel(graph) {
const central = String(graph?.centralStatement || "these observations")
.trim()
.replace(/[.?!:;]+$/g, "");
return `Explanation for why ${central}`;
}
function findEquivalentEmergentUnknown(graph, label, description) {
const targetId = makeNodeId(label);
const targetTexts = [normaliseText(label), normaliseText(description)].filter(
Boolean,
);
return (graph.nodes || []).find((node) => {
if (
node.kind !== "unknown" ||
(graph.resolvedNodeIds || []).includes(node.id)
) {
return false;
}
if (node.id === targetId) {
return true;
}
const nodeTexts = [
normaliseText(node.label),
normaliseText(node.description),
].filter(Boolean);
return targetTexts.some((text) => nodeTexts.includes(text));
});
}
function buildEmergentReasoningUnknown(graph, relationshipAssessment) {
if (!relationshipAssessment?.relationshipAssessed) {
return null;
}
if (!relationshipAssessment.questionRequired) {
return null;
}
if (
![
"potentially_related",
"insufficient_information",
"contradictory",
].includes(relationshipAssessment.relationshipStatus)
) {
return null;
}
const label = buildEmergentReasoningUnknownLabel(graph);
const description =
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.";
const existingNode = findEquivalentEmergentUnknown(graph, label, description);
if (existingNode) {
return {
created: false,
node: existingNode,
edges: [],
reason:
"Reused an existing unresolved reasoning unknown for the next investigation stage.",
};
}
const observationNodes = (graph.nodes || []).filter(
(node) => node.kind === "observation" && node.status === "supported",
);
const relationshipNode = (graph.nodes || []).find(
(node) => node.kind === "relationship" && node.status === "supported",
);
const nodeId = makeNodeId(label);
const relatedNodeIds = relationshipNode
? [relationshipNode.id]
: observationNodes.slice(0, 2).map((node) => node.id);
if (relatedNodeIds.length === 0) {
return null;
}
const node = {
id: nodeId,
label,
description,
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: relatedNodeIds,
affects: [],
parentId: relationshipNode?.id ?? null,
childIds: [],
};
const edges = relatedNodeIds.map((relatedNodeId) => ({
id: `e-${relatedNodeId.slice(0, 6)}-${nodeId.slice(0, 6)}`,
fromNodeId: relatedNodeId,
toNodeId: nodeId,
relationship:
relationshipNode?.id === relatedNodeId ? "depends_on" : "other",
confidence: "medium",
description:
"This unresolved explanation arises from the now-assessed relationship between the observations.",
}));
return {
created: true,
node,
edges,
reason:
"Created a new unresolved reasoning unknown so the next justified question is backed by the graph.",
};
}
function stripTrailingPunctuation(value) {
return String(value || "")
.trim()
.replace(/[.?!:;]+$/g, "")
.trim();
}
function collectSupportedObservations(graph) {
return (graph.nodes || []).filter(
(node) => node.kind === "observation" && node.status === "supported",
);
}
function detectObservationConcept(text) {
const normalised = normaliseText(text);
const concepts = [
["revenue", /\brevenue\b/],
["cash", /\bcash\b/],
["customer satisfaction", /\bsatisfaction\b/],
["complaints", /\bcomplaints?\b/],
["delivery time", /\bdelivery time\b|\bdelivery\b/],
["cancellations", /\bcancellations?\b/],
["traffic", /\btraffic\b/],
["sales", /\bsales\b/],
["production", /\bproduction\b|\boutput\b/],
["defects", /\bdefects?\b/],
["quality", /\bquality\b/],
];
for (const [label, pattern] of concepts) {
if (pattern.test(normalised)) return label;
}
return null;
}
function buildDecompositionContext(graph) {
const observations = collectSupportedObservations(graph);
const firstObservation = observations[0] ?? null;
const secondObservation = observations[1] ?? null;
const firstConcept = detectObservationConcept(
`${firstObservation?.label || ""} ${firstObservation?.description || ""}`,
);
const secondConcept = detectObservationConcept(
`${secondObservation?.label || ""} ${secondObservation?.description || ""}`,
);
return {
centralStatement: stripTrailingPunctuation(graph.centralStatement),
firstConcept: firstConcept || "the first signal",
secondConcept: secondConcept || "the second signal",
firstObservationLabel: stripTrailingPunctuation(
firstObservation?.label || "",
),
secondObservationLabel: stripTrailingPunctuation(
secondObservation?.label || "",
),
};
}
export const MAX_DECOMPOSITION_DEPTH = 2;
function splitSemanticTokens(value) {
return normaliseText(value)
.split(" ")
.filter((token) => token.length > 2);
}
function buildSemanticSignature(node) {
return normaliseText(`${node?.label || ""} ${node?.description || ""}`);
}
function calculateTokenOverlapRatio(aTokens, bTokens) {
const a = new Set(aTokens);
const b = new Set(bTokens);
const intersection = [...a].filter((token) => b.has(token)).length;
const largest = Math.max(a.size, b.size, 1);
return intersection / largest;
}
function detectCompoundSignals(text) {
const signals = [];
if (/\b(and|or)\b/.test(text)) {
if (
/\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b[^.]{0,30}\b(and|or)\b[^.]{0,30}\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b/.test(
text,
)
) {
signals.push("conjoined_distinct_concepts");
}
}
if (/\b[a-z]+\s*\/\s*[a-z]+\b/.test(text)) {
signals.push("slash_separated_categories");
}
if (/,[^,]{0,20},/.test(text) || /,\s*[^,]+\s+or\s+[^,]+/.test(text)) {
signals.push("comma_separated_category_list");
}
if (/\btiming or measurement basis\b/.test(text)) {
signals.push("timing_or_measurement_basis");
}
return [...new Set(signals)];
}
function isDirectlyAnswerableChildText(text) {
return !/\b(explanation for why|possible causes|possible reasons|what changed|difference between|divergence|moved differently|factors behind|factors affecting)\b/.test(
text,
);
}
function buildRejectedChildRecord(childNode, reasons, compoundSignals) {
return {
nodeId: childNode.id,
label: childNode.label,
reasons,
compoundSignals,
};
}
function mergeUniqueRecords(existing = [], incoming = [], key = "nodeId") {
const merged = new Map((existing || []).map((item) => [item[key], item]));
for (const item of incoming || []) {
merged.set(item[key], item);
}
return [...merged.values()];
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node));
}
export function assessChildUnknownQuality({
parentNode,
childNode,
siblingNodes,
graph,
}) {
const parentSignature = buildSemanticSignature(parentNode);
const childSignature = buildSemanticSignature(childNode);
const parentTokens = splitSemanticTokens(parentSignature);
const childTokens = splitSemanticTokens(childSignature);
const compoundSignals = detectCompoundSignals(childSignature);
const duplicateSiblingIds = (siblingNodes || [])
.filter((sibling) => sibling.id !== childNode.id)
.filter((sibling) => buildSemanticSignature(sibling) === childSignature)
.map((sibling) => sibling.id);
const reasons = [];
const narrowerThanParent =
childSignature !== parentSignature &&
(childTokens.length < parentTokens.length ||
calculateTokenOverlapRatio(parentTokens, childTokens) < 0.8);
const directlyAnswerable =
isDirectlyAnswerableChildText(childSignature) &&
compoundSignals.length === 0;
const independent =
duplicateSiblingIds.length === 0 && compoundSignals.length === 0;
const atomic = narrowerThanParent && directlyAnswerable && independent;
if (!narrowerThanParent) {
reasons.push("not_narrower_than_parent");
}
if (!directlyAnswerable) {
reasons.push("not_directly_answerable");
}
if (compoundSignals.length > 0) {
reasons.push("compound_child");
}
if (duplicateSiblingIds.length > 0) {
reasons.push("duplicate_sibling");
}
if ((graph?.resolvedNodeIds || []).includes(childNode.id)) {
reasons.push("already_resolved");
}
return {
valid: reasons.length === 0,
atomic,
directlyAnswerable,
independent,
narrowerThanParent,
compoundSignals,
duplicateSiblingIds,
reasons,
};
}
function buildDecompositionChildId(parentNodeId, label) {
return makeNodeId(`${parentNodeId}:${label}`);
}
function findEquivalentDecompositionChild(
graph,
parentNodeId,
label,
description,
) {
const targetId = buildDecompositionChildId(parentNodeId, label);
const targetTexts = [normaliseText(label), normaliseText(description)].filter(
Boolean,
);
return (graph.nodes || []).find((node) => {
if (
node.kind !== "unknown" ||
node.parentId !== parentNodeId ||
(graph.resolvedNodeIds || []).includes(node.id)
) {
return false;
}
if (node.id === targetId) {
return true;
}
const nodeTexts = [
normaliseText(node.label),
normaliseText(node.description),
].filter(Boolean);
return targetTexts.some((text) => nodeTexts.includes(text));
});
}
function describeObservationFocus(context, which) {
const concept =
which === "first" ? context.firstConcept : context.secondConcept;
const label =
which === "first"
? context.firstObservationLabel
: context.secondObservationLabel;
if (concept && !concept.startsWith("the ")) return concept;
if (label) return label.toLowerCase();
return which === "first" ? "the first observation" : "the second observation";
}
function buildDecompositionTemplates(parentNode, graph, depth = 0) {
const context = buildDecompositionContext(graph);
const firstFocus = describeObservationFocus(context, "first");
const secondFocus = describeObservationFocus(context, "second");
if (/\btiming or measurement basis\b/i.test(parentNode.label)) {
return [
{
label: "Whether the two observations reflect different timing",
description: `Need to know whether the two observations reflect different timing, because that would help resolve ${context.centralStatement}.`,
},
{
label: "How the two observations were measured",
description: `Need evidence about the measure used for each observation, because that would help resolve ${context.centralStatement}.`,
},
];
}
return [
{
label: "Whether the two observations reflect different timing",
description: `Need to know whether the two observations reflect different timing, because that could help explain ${context.centralStatement}.`,
},
{
label: "How the two observations were measured",
description: `Need evidence about the measure used for each observation, because that could help explain ${context.centralStatement}.`,
},
{
label: `Possible change mainly affecting ${firstFocus}`,
description: `Need to know whether a possible change mainly affected ${firstFocus}, because that could help explain ${context.centralStatement}.`,
},
{
label: `Possible change mainly affecting ${secondFocus}`,
description: `Need to know whether a possible change mainly affected ${secondFocus}, because that could help explain ${context.centralStatement}.`,
},
depth === 0
? {
label: "Possible one-off event during the period",
description: `Need to know whether a possible one-off event happened during the period, because that could help explain ${context.centralStatement}.`,
}
: {
label: "Mix shift during the period",
description: `Need to know whether the mix of cases, customers, or items shifted during the period, because that could help explain ${context.centralStatement}.`,
},
];
}
function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
const templates = buildDecompositionTemplates(parentNode, graph, depth);
const candidateNodes = templates.map(
(template) =>
findEquivalentDecompositionChild(
graph,
parentNode.id,
template.label,
template.description,
) || {
id: buildDecompositionChildId(parentNode.id, template.label),
label: template.label,
description: template.description,
kind: "unknown",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: parentNode.id,
childIds: [],
},
);
const childNodes = [];
const childEdges = [];
const childNodeIds = [];
const rejectedChildren = [];
const childQualitySummary = [];
for (const childNode of candidateNodes) {
const quality = assessChildUnknownQuality({
parentNode,
childNode,
siblingNodes: candidateNodes,
graph,
});
childQualitySummary.push({
nodeId: childNode.id,
label: childNode.label,
valid: quality.valid,
atomic: quality.atomic,
reasons: quality.reasons,
});
if (!quality.valid) {
rejectedChildren.push(
buildRejectedChildRecord(
childNode,
quality.reasons,
quality.compoundSignals,
),
);
continue;
}
childNodeIds.push(childNode.id);
if ((graph.nodes || []).some((node) => node.id === childNode.id)) {
continue;
}
childNodes.push(childNode);
childEdges.push({
id: `e-${childNode.id.slice(0, 6)}-${parentNode.id.slice(0, 6)}`,
fromNodeId: childNode.id,
toNodeId: parentNode.id,
relationship: "depends_on",
confidence: "medium",
description:
"This child unknown must be investigated before the broader parent explanation can be resolved.",
});
}
const acceptedChildCount = childNodeIds.length;
const proposedChildCount = candidateNodes.length;
if (acceptedChildCount < 2) {
return {
accepted: false,
childNodes: [],
childEdges: [],
childNodeIds: [],
proposedChildCount,
acceptedChildCount,
rejectedChildren,
childQualitySummary,
reason:
acceptedChildCount === 0
? "Decomposition stopped because all proposed children failed quality checks."
: "Decomposition stopped because fewer than two valid child unknowns remained after quality checks.",
};
}
return {
accepted: true,
childNodes,
childEdges,
childNodeIds,
proposedChildCount,
acceptedChildCount,
rejectedChildren,
childQualitySummary,
reason:
childNodes.length > 0
? "Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question."
: "Reused existing decomposition children for the composite unknown before asking the next question.",
};
}
function findNodeById(graph, nodeId) {
return (graph.nodes || []).find((node) => node.id === nodeId) || null;
}
function runDeterministicDecomposition({
graphSnapshot,
proposalSnapshot,
updatedSituationGraph,
reasoningResolution,
deterministicSelection,
}) {
let workingGraph = updatedSituationGraph;
let workingSelection = deterministicSelection;
let workingProposal = proposalSnapshot;
let nextReasoningState = workingGraph.reasoningState;
let lastAtomicityAssessment = null;
let rootAtomicityAssessment = null;
let decompositionDepth = 0;
let decompositionAttempted = false;
let decompositionAccepted = false;
let proposedChildCount = 0;
let acceptedChildCount = 0;
let selectedChildNodeId = null;
let decompositionStoppedReason = null;
let rejectedChildren = [];
let childQualitySummary = [];
while (workingSelection?.status === "selected" && workingSelection?.nodeId) {
const selectedNode = findNodeById(workingGraph, workingSelection.nodeId);
if (!selectedNode) {
decompositionStoppedReason =
"Selected node was not present in the updated graph.";
break;
}
const atomicityAssessment = assessUnknownAtomicity({
node: selectedNode,
graph: workingGraph,
});
lastAtomicityAssessment = atomicityAssessment;
if (!rootAtomicityAssessment) {
rootAtomicityAssessment = atomicityAssessment;
}
if (atomicityAssessment.atomicity === "atomic") {
selectedChildNodeId = decompositionDepth > 0 ? selectedNode.id : null;
decompositionStoppedReason =
decompositionDepth > 0
? "Selected child is atomic and directly answerable."
: "Selected unknown is already atomic.";
break;
}
if (hasExistingDecompositionChildren(workingGraph, selectedNode.id)) {
decompositionStoppedReason =
"Selected composite parent already has decomposition children, so they should be reused instead of regenerated.";
break;
}
if (decompositionDepth >= MAX_DECOMPOSITION_DEPTH) {
decompositionStoppedReason =
"Maximum decomposition depth reached before finding a smaller atomic child.";
break;
}
decompositionAttempted = true;
const decomposition = buildCompositeUnknownChildren(
selectedNode,
workingGraph,
decompositionDepth,
);
proposedChildCount = decomposition.proposedChildCount;
acceptedChildCount = decomposition.acceptedChildCount;
rejectedChildren = mergeUniqueRecords(
rejectedChildren,
decomposition.rejectedChildren,
);
childQualitySummary = mergeUniqueRecords(
childQualitySummary,
decomposition.childQualitySummary,
);
if (!decomposition.accepted) {
decompositionStoppedReason = decomposition.reason;
break;
}
const previousGraph = cloneJsonSafe(workingGraph);
const previousProposal = cloneJsonSafe(workingProposal);
const previousReasoningState = cloneJsonSafe(nextReasoningState);
workingProposal.addedNodes.push(...decomposition.childNodes.map(cloneNode));
workingProposal.addedEdges.push(...decomposition.childEdges.map(cloneNode));
const applied = applyGraphUpdate(graphSnapshot, workingProposal);
if (!applied.success) {
return {
success: false,
stage: "application",
errors: applied.errors,
};
}
workingGraph = {
...graphSnapshot,
nodes: applied.nodes,
edges: applied.edges,
resolvedNodeIds: applied.resolvedNodeIds,
};
nextReasoningState = buildReasoningState(
workingGraph,
reasoningResolution.reasoningStateOverride,
);
workingGraph.reasoningState = nextReasoningState;
workingSelection = selectActiveUnknownCandidate(
workingGraph,
workingGraph.resolvedNodeIds,
);
if (workingSelection?.status !== "selected") {
workingGraph = previousGraph;
workingProposal = previousProposal;
nextReasoningState = previousReasoningState;
workingSelection = selectActiveUnknownCandidate(
workingGraph,
workingGraph.resolvedNodeIds,
);
decompositionStoppedReason =
workingSelection?.status === "ambiguous"
? "Decomposition produced multiple equally valid children with no justified distinction."
: "No unresolved child remained selectable after decomposition.";
break;
}
decompositionAccepted = true;
decompositionDepth += 1;
}
return {
success: true,
updatedSituationGraph: workingGraph,
proposalSnapshot: workingProposal,
reasoningState: nextReasoningState,
deterministicSelection: workingSelection,
atomicityAssessment:
rootAtomicityAssessment ?? lastAtomicityAssessment ?? null,
decompositionDepth,
decompositionAttempted,
decompositionAccepted,
decompositionStoppedReason,
proposedChildCount,
acceptedChildCount,
rejectedChildren,
childQualitySummary,
selectedChildNodeId,
};
}
function isComparabilityQuestion(question) {
const text = String(question || "").toLowerCase();
return (
text.includes("same basis") ||
text.includes("same scale") ||
text.includes("same period")
);
}
function answerConfirmsComparability(answer) {
const text = String(answer || "").toLowerCase();
return (
/\byes\b/.test(text) &&
(text.includes("same accounting period") ||
text.includes("same management accounts") ||
text.includes("same basis") ||
text.includes("same scale") ||
text.includes("both figures cover the same"))
);
}
function deriveReasoningStateOverride({
graph,
previousQuestion,
answer,
resolvedUnknownNodeIds,
}) {
const previousReasoningState = buildReasoningState(graph);
const previousComparabilityStatus =
previousReasoningState.comparabilityStatus ?? null;
if (
previousComparabilityStatus === "uncertain" &&
isComparabilityQuestion(previousQuestion) &&
answerConfirmsComparability(answer)
) {
return {
reasoningStateOverride: {
comparabilityStatus: "confirmed",
comparabilityReason:
"Comparability was confirmed by the user answer covering the same period and source basis.",
comparabilityEvidence: resolvedUnknownNodeIds,
},
resolvedReasoningNodeIds: [COMPARABILITY_REASONING_NODE_ID],
previousReasoningState,
};
}
return {
reasoningStateOverride: {},
resolvedReasoningNodeIds: [],
previousReasoningState,
};
}
export function applyValidatedProposal({
situationGraph,
proposal,
previousQuestion = null,
answer = null,
}) {
const graphValidation = situationGraphSchema.safeParse(situationGraph);
const proposalValidation = graphUpdateSchema.safeParse(proposal);
const existingGraphReferenceValidation = graphValidation.success
? validateGraphReferences(situationGraph)
: null;
const existingDuplicateNodeIds = graphValidation.success
? detectDuplicateNodeIds(situationGraph.nodes)
: [];
const existingDuplicateEdgeIds = graphValidation.success
? collectDuplicateEdgeIds(situationGraph.edges)
: [];
if (
!graphValidation.success ||
!existingGraphReferenceValidation?.valid ||
existingDuplicateNodeIds.length > 0 ||
existingDuplicateEdgeIds.length > 0
) {
return {
success: false,
stage: "graph_validation",
errors: [
...(!graphValidation.success
? zodIssuesToErrors(graphValidation.error)
: []),
...(!existingGraphReferenceValidation?.valid
? existingGraphReferenceValidation.errors
: []),
...existingDuplicateNodeIds.map(
({ nodeId, count }) =>
`Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
),
...existingDuplicateEdgeIds.map(
({ edgeId, count }) =>
`Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
),
],
};
}
if (!proposalValidation.success) {
return {
success: false,
stage: "proposal_compatibility",
errors: zodIssuesToErrors(proposalValidation.error),
};
}
const reconciledProposal = reconcileResolutionSemantics(
situationGraph,
proposalValidation.data,
);
const validatedProposal = reconciledProposal.proposal;
const proposalCompatibilityErrors = [];
proposalCompatibilityErrors.push(...reconciledProposal.errors);
const proposalGraphValidation = validateGraphUpdate(
situationGraph,
validatedProposal,
);
if (!proposalGraphValidation.valid) {
proposalCompatibilityErrors.push(...proposalGraphValidation.errors);
}
const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id));
const reachableNodeIds = new Set([
...situationGraph.nodes.map((node) => node.id),
...validatedProposal.addedNodes.map((node) => node.id),
]);
const addedEdgeDuplicateIds = collectDuplicateEdgeIds(
validatedProposal.addedEdges,
);
proposalCompatibilityErrors.push(
...addedEdgeDuplicateIds.map(
({ edgeId, count }) =>
`Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`,
),
);
for (const edge of validatedProposal.addedEdges) {
if (existingEdgeIds.has(edge.id)) {
proposalCompatibilityErrors.push(
`Cannot add edge with duplicate ID: "${edge.id}"`,
);
}
if (!reachableNodeIds.has(edge.fromNodeId)) {
proposalCompatibilityErrors.push(
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
);
}
if (!reachableNodeIds.has(edge.toNodeId)) {
proposalCompatibilityErrors.push(
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
);
}
}
const removedEdgeIds = new Set(validatedProposal.removedEdgeIds);
for (const edgeId of removedEdgeIds) {
if (!existingEdgeIds.has(edgeId)) {
proposalCompatibilityErrors.push(
`Cannot remove non-existent edge: "${edgeId}"`,
);
}
}
const combinedNodeDuplicates = detectDuplicateNodeIds([
...situationGraph.nodes,
...validatedProposal.addedNodes,
]);
proposalCompatibilityErrors.push(
...combinedNodeDuplicates.map(
({ nodeId, count }) =>
`Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`,
),
);
proposalCompatibilityErrors.push(
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
);
proposalCompatibilityErrors.push(
...validateAddedUnknowns(situationGraph, validatedProposal),
);
const selectedQuestionValidation = validateSelectedQuestion(
situationGraph,
validatedProposal,
);
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
proposalCompatibilityErrors.push(
...validateQuestionSelectionRequirement(situationGraph, validatedProposal),
);
if (proposalCompatibilityErrors.length > 0) {
return {
success: false,
stage: "proposal_compatibility",
errors: proposalCompatibilityErrors,
};
}
const graphSnapshot = cloneJsonSafe(situationGraph);
const proposalSnapshot = cloneJsonSafe(validatedProposal);
const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null;
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
const reasoningResolution = deriveReasoningStateOverride({
graph: graphSnapshot,
previousQuestion,
answer,
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
});
const provisionalApplied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
if (!provisionalApplied.success) {
return {
success: false,
stage: "application",
errors: provisionalApplied.errors,
};
}
const provisionalGraph = {
...graphSnapshot,
nodes: provisionalApplied.nodes,
edges: provisionalApplied.edges,
resolvedNodeIds: provisionalApplied.resolvedNodeIds,
};
provisionalGraph.reasoningState = buildReasoningState(
provisionalGraph,
reasoningResolution.reasoningStateOverride,
);
const relationshipAssessment =
classifyObservationRelationship(provisionalGraph);
const emergentReasoningUnknown = buildEmergentReasoningUnknown(
provisionalGraph,
relationshipAssessment,
);
if (emergentReasoningUnknown?.created) {
proposalSnapshot.addedNodes.push(emergentReasoningUnknown.node);
proposalSnapshot.addedEdges.push(...emergentReasoningUnknown.edges);
}
let applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
if (!applied.success) {
return {
success: false,
stage: "application",
errors: applied.errors,
};
}
let updatedSituationGraph = {
...graphSnapshot,
nodes: applied.nodes,
edges: applied.edges,
resolvedNodeIds: applied.resolvedNodeIds,
};
let nextReasoningState = buildReasoningState(
updatedSituationGraph,
reasoningResolution.reasoningStateOverride,
);
updatedSituationGraph.reasoningState = nextReasoningState;
const activeUnknownWasResolved =
previousActiveUnknownNodeId != null &&
updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId);
let newActiveUnknownNodeId = previousActiveUnknownNodeId;
if (activeUnknownWasResolved) {
newActiveUnknownNodeId = null;
}
if (validatedProposal.selectedQuestion?.nodeId) {
newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId;
}
const remainingUnknownExists =
newActiveUnknownNodeId != null &&
updatedSituationGraph.nodes.some(
(node) =>
node.id === newActiveUnknownNodeId &&
node.kind === "unknown" &&
!updatedSituationGraph.resolvedNodeIds.includes(node.id),
);
if (!remainingUnknownExists) {
newActiveUnknownNodeId =
selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
)?.nodeId ?? null;
}
let deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
const decompositionResult = runDeterministicDecomposition({
graphSnapshot,
proposalSnapshot,
updatedSituationGraph,
reasoningResolution,
deterministicSelection,
});
if (!decompositionResult.success) {
return decompositionResult;
}
updatedSituationGraph = decompositionResult.updatedSituationGraph;
nextReasoningState = decompositionResult.reasoningState;
deterministicSelection = decompositionResult.deterministicSelection;
const propagationResult = propagateResolvedChildEvidence({
updatedSituationGraph,
proposalSnapshot: decompositionResult.proposalSnapshot,
});
updatedSituationGraph = propagationResult.graph;
nextReasoningState = buildReasoningState(
updatedSituationGraph,
reasoningResolution.reasoningStateOverride,
);
updatedSituationGraph.reasoningState = nextReasoningState;
deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
const atomicityAssessment = decompositionResult.atomicityAssessment;
const decompositionDepth = decompositionResult.decompositionDepth;
const decompositionAttempted = decompositionResult.decompositionAttempted;
const decompositionAccepted = decompositionResult.decompositionAccepted;
const decompositionStoppedReason =
decompositionResult.decompositionStoppedReason;
const proposedChildCount = decompositionResult.proposedChildCount;
const acceptedChildCount = decompositionResult.acceptedChildCount;
const rejectedChildren = decompositionResult.rejectedChildren;
const childQualitySummary = decompositionResult.childQualitySummary;
const selectedChildNodeId = decompositionResult.selectedChildNodeId;
const decompositionPerformed =
decompositionAttempted && decompositionAccepted;
const decompositionChildNodeIds = [
...new Set(
decompositionResult.proposalSnapshot.addedNodes
.filter((node) => node.kind === "unknown" && node.parentId != null)
.map((node) => node.id),
),
];
const decompositionReason = decompositionStoppedReason;
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 affectedAncestorIds = propagationResult.affectedAncestorIds;
const nextSelectedSibling = propagationResult.nextSelectedSibling;
const parentResolved = propagationResult.parentResolved;
const propagationReason = propagationResult.reason;
if (
deterministicSelection?.status === "selected" &&
deterministicSelection?.nodeId
) {
newActiveUnknownNodeId = deterministicSelection.nodeId;
} else if (deterministicSelection?.status === "ambiguous") {
newActiveUnknownNodeId = null;
}
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
const selectedNode =
deterministicSelection?.status === "selected" &&
deterministicSelection?.nodeId
? updatedSituationGraph.nodes.find(
(node) => node.id === deterministicSelection.nodeId,
)
: null;
const formulatedQuestion = selectedNode
? formulateQuestion({
node: selectedNode,
graph: updatedSituationGraph,
context: {
resolvedValues: validatedProposal.updatedNodes
.map((update) => update.newValue)
.filter(
(value) => typeof value === "string" && value.trim().length > 0,
),
selectionState: deterministicSelection,
},
})
: null;
const finalSelectedQuestion =
deterministicSelection?.status === "ambiguous"
? {
nodeId: null,
tiedCandidateIds: deterministicSelection.tiedCandidateIds,
question: null,
reason: deterministicSelection.reason,
}
: deterministicSelection?.status === "selected"
? {
nodeId: deterministicSelection.nodeId,
question:
formulatedQuestion?.question || deterministicSelection.question,
reason: formulatedQuestion?.reason || deterministicSelection.reason,
strategy: formulatedQuestion?.strategy,
investigationStrategy: formulatedQuestion?.investigationStrategy,
}
: null;
const resultGraphValidation = situationGraphSchema.safeParse(
updatedSituationGraph,
);
const resultReferenceValidation = resultGraphValidation.success
? validateGraphReferences(updatedSituationGraph)
: null;
const resultDuplicateNodeIds = resultGraphValidation.success
? detectDuplicateNodeIds(updatedSituationGraph.nodes)
: [];
const resultDuplicateEdgeIds = resultGraphValidation.success
? collectDuplicateEdgeIds(updatedSituationGraph.edges)
: [];
if (
!resultGraphValidation.success ||
!resultReferenceValidation?.valid ||
resultDuplicateNodeIds.length > 0 ||
resultDuplicateEdgeIds.length > 0
) {
return {
success: false,
stage: "result_validation",
errors: [
...(!resultGraphValidation.success
? zodIssuesToErrors(resultGraphValidation.error)
: []),
...(!resultReferenceValidation?.valid
? resultReferenceValidation.errors
: []),
...resultDuplicateNodeIds.map(
({ nodeId, count }) =>
`Updated graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
),
...resultDuplicateEdgeIds.map(
({ edgeId, count }) =>
`Updated graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
),
],
};
}
return {
success: true,
updatedSituationGraph,
graphUpdate: proposalSnapshot,
affectedNodeIds,
resolvedUnknownNodeIds: proposalSnapshot.resolvedUnknownNodeIds,
resolvedReasoningNodeIds: reasoningResolution.resolvedReasoningNodeIds,
emergentReasoningNodeCreated: Boolean(emergentReasoningUnknown?.created),
emergentReasoningNodeId: emergentReasoningUnknown?.node?.id ?? null,
emergentReasoningNodeReason: emergentReasoningUnknown?.reason ?? null,
atomicityAssessment: atomicityAssessment?.atomicity ?? null,
atomicityDecisionReason: atomicityAssessment?.reason ?? null,
decompositionDepth,
decompositionAttempted,
decompositionAccepted,
decompositionStoppedReason,
proposedChildCount,
acceptedChildCount,
rejectedChildren,
selectedChildNodeId,
childQualitySummary,
propagationPerformed,
resolvedChildNodeId,
parentNodeId,
parentStatusBefore,
parentStatusAfter,
parentConfidenceBefore,
parentConfidenceAfter,
affectedAncestorIds,
nextSelectedSibling,
parentResolved,
decompositionPerformed,
childUnknownCount: decompositionChildNodeIds.length,
childNodeIds: decompositionChildNodeIds,
atomicityReason:
propagationReason ||
decompositionReason ||
atomicityAssessment?.reason ||
null,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion: finalSelectedQuestion,
changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds),
graphReferenceValidation: resultReferenceValidation,
previousReasoningState: reasoningResolution.previousReasoningState,
reasoningState: nextReasoningState,
};
}