2248 lines
70 KiB
JavaScript
2248 lines
70 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 getNodeConfidenceAssessment(node) {
|
|
return (
|
|
node?.confidenceAssessment || {
|
|
evidenceConfidence: node?.confidence ?? "medium",
|
|
completenessStatus:
|
|
node?.status === "resolved"
|
|
? "complete"
|
|
: node?.status === "provisional"
|
|
? "partial"
|
|
: "empty",
|
|
conclusionConfidence:
|
|
node?.status === "resolved"
|
|
? (node?.confidence ?? "high")
|
|
: node?.status === "provisional"
|
|
? (node?.confidence ?? "medium")
|
|
: "low",
|
|
}
|
|
);
|
|
}
|
|
|
|
function confidenceFromAssessment(assessment) {
|
|
return assessment?.conclusionConfidence ?? "medium";
|
|
}
|
|
|
|
function unique(values = []) {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
function branchEvidenceKeys(node) {
|
|
return unique([...(node?.evidenceIds || []), node?.value]);
|
|
}
|
|
|
|
function sharedMeaningfulTokens(aText, bText) {
|
|
const stop = new Set([
|
|
"the",
|
|
"and",
|
|
"for",
|
|
"that",
|
|
"this",
|
|
"with",
|
|
"from",
|
|
"because",
|
|
"need",
|
|
"unknown",
|
|
"possible",
|
|
]);
|
|
const a = splitSemanticTokens(aText).filter((token) => !stop.has(token));
|
|
const b = splitSemanticTokens(bText).filter((token) => !stop.has(token));
|
|
return [...new Set(a.filter((token) => b.includes(token)))];
|
|
}
|
|
|
|
function branchConflictSignature(node) {
|
|
return normaliseText(
|
|
`${node?.label || ""} ${node?.description || ""} ${node?.value || ""}`,
|
|
);
|
|
}
|
|
|
|
function branchesConflict(aNode, bNode) {
|
|
const aText = branchConflictSignature(aNode);
|
|
const bText = branchConflictSignature(bNode);
|
|
const oppositePolarity =
|
|
(aText.includes("correctly") && bText.includes("incorrectly")) ||
|
|
(aText.includes("incorrectly") && bText.includes("correctly")) ||
|
|
aNode?.status === "contradicted" ||
|
|
bNode?.status === "contradicted";
|
|
|
|
if (!oppositePolarity) return false;
|
|
|
|
return sharedMeaningfulTokens(aText, bText).length >= 2;
|
|
}
|
|
|
|
export function evaluateBranchInteractions({ parentNode, graph }) {
|
|
const directBranches = findDirectChildUnknowns(graph, parentNode.id).filter(
|
|
(node) => ["resolved", "provisional", "contradicted"].includes(node.status),
|
|
);
|
|
const duplicateEvidenceGroups = [];
|
|
const conflictingBranches = [];
|
|
const corroboratingBranches = [];
|
|
const duplicateBranchIds = new Set();
|
|
const conflictingBranchIds = new Set();
|
|
|
|
const evidenceGroups = new Map();
|
|
for (const branch of directBranches) {
|
|
for (const evidenceKey of branchEvidenceKeys(branch)) {
|
|
const ids = evidenceGroups.get(evidenceKey) || [];
|
|
ids.push(branch.id);
|
|
evidenceGroups.set(evidenceKey, ids);
|
|
}
|
|
}
|
|
|
|
for (const [evidenceKey, branchIds] of evidenceGroups.entries()) {
|
|
if (branchIds.length > 1) {
|
|
duplicateEvidenceGroups.push({
|
|
evidenceKey,
|
|
branchIds: unique(branchIds),
|
|
});
|
|
for (const id of branchIds) duplicateBranchIds.add(id);
|
|
}
|
|
}
|
|
|
|
for (let index = 0; index < directBranches.length; index += 1) {
|
|
for (let inner = index + 1; inner < directBranches.length; inner += 1) {
|
|
const aNode = directBranches[index];
|
|
const bNode = directBranches[inner];
|
|
if (branchesConflict(aNode, bNode)) {
|
|
conflictingBranches.push([aNode.id, bNode.id]);
|
|
conflictingBranchIds.add(aNode.id);
|
|
conflictingBranchIds.add(bNode.id);
|
|
continue;
|
|
}
|
|
|
|
const aEvidence = branchEvidenceKeys(aNode);
|
|
const bEvidence = branchEvidenceKeys(bNode);
|
|
const sharesEvidence = aEvidence.some((key) => bEvidence.includes(key));
|
|
if (
|
|
!sharesEvidence &&
|
|
aNode.status === "resolved" &&
|
|
bNode.status === "resolved"
|
|
) {
|
|
corroboratingBranches.push([aNode.id, bNode.id]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const interactionBranchIds = new Set([
|
|
...duplicateBranchIds,
|
|
...conflictingBranchIds,
|
|
...corroboratingBranches.flat(),
|
|
]);
|
|
const independentBranches = directBranches
|
|
.map((branch) => branch.id)
|
|
.filter((id) => !interactionBranchIds.has(id));
|
|
|
|
return {
|
|
corroboratingBranches,
|
|
conflictingBranches,
|
|
duplicateEvidenceGroups,
|
|
independentBranches,
|
|
interactionSummary: {
|
|
corroboratingBranchCount: corroboratingBranches.length,
|
|
conflictingBranchCount: conflictingBranches.length,
|
|
duplicateEvidenceCount: duplicateEvidenceGroups.length,
|
|
independentBranchCount: independentBranches.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
function upsertProposalNodeUpdate(proposalSnapshot, update) {
|
|
const existing = proposalSnapshot.updatedNodes.find(
|
|
(candidate) => candidate.nodeId === update.nodeId,
|
|
);
|
|
|
|
if (existing) {
|
|
if (update.newStatus != null) existing.newStatus = update.newStatus;
|
|
if (update.newValue !== undefined) existing.newValue = update.newValue;
|
|
if (existing.previousStatus == null) {
|
|
existing.previousStatus = update.previousStatus ?? null;
|
|
}
|
|
if (existing.previousValue === undefined) {
|
|
existing.previousValue = update.previousValue ?? null;
|
|
}
|
|
existing.reason = update.reason;
|
|
return existing;
|
|
}
|
|
|
|
proposalSnapshot.updatedNodes.push(update);
|
|
return update;
|
|
}
|
|
|
|
function ensureResolvedUnknownId(proposalSnapshot, nodeId) {
|
|
if (!proposalSnapshot.resolvedUnknownNodeIds.includes(nodeId)) {
|
|
proposalSnapshot.resolvedUnknownNodeIds.push(nodeId);
|
|
}
|
|
}
|
|
|
|
function buildPropagationEvidenceId(nodeId) {
|
|
return `answer:${nodeId}`;
|
|
}
|
|
|
|
function findDirectChildUnknowns(graph, parentNodeId) {
|
|
const parentNode = (graph.nodes || []).find(
|
|
(node) => node.id === parentNodeId,
|
|
);
|
|
const childIds = new Set(parentNode?.childIds || []);
|
|
|
|
for (const edge of graph.edges || []) {
|
|
if (edge.toNodeId === parentNodeId && edge.relationship === "depends_on") {
|
|
childIds.add(edge.fromNodeId);
|
|
}
|
|
}
|
|
|
|
return (graph.nodes || []).filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
(node.parentId === parentNodeId || childIds.has(node.id)),
|
|
);
|
|
}
|
|
|
|
function hasExistingDecompositionChildren(graph, parentNodeId) {
|
|
return findDirectChildUnknowns(graph, parentNodeId).length > 0;
|
|
}
|
|
|
|
function buildAncestorChain(graph, node) {
|
|
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
|
|
const chain = [];
|
|
const queue = [node?.parentId ?? null].filter(Boolean);
|
|
const seen = new Set();
|
|
|
|
while (queue.length > 0) {
|
|
const currentParentId = queue.shift();
|
|
if (!currentParentId || seen.has(currentParentId)) continue;
|
|
seen.add(currentParentId);
|
|
|
|
const parentNode = nodesById.get(currentParentId);
|
|
if (!parentNode) continue;
|
|
chain.push(parentNode);
|
|
|
|
if (parentNode.parentId) {
|
|
queue.push(parentNode.parentId);
|
|
}
|
|
|
|
for (const candidate of graph.nodes || []) {
|
|
if (
|
|
candidate.id !== parentNode.id &&
|
|
(candidate.childIds || []).includes(parentNode.id)
|
|
) {
|
|
queue.push(candidate.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return chain;
|
|
}
|
|
|
|
function syncParentChildReferences(graph) {
|
|
const nodesById = new Map((graph.nodes || []).map((node) => [node.id, node]));
|
|
|
|
for (const node of graph.nodes || []) {
|
|
if (!node.parentId) continue;
|
|
const parentNode = nodesById.get(node.parentId);
|
|
if (!parentNode) continue;
|
|
|
|
parentNode.childIds = appendUniqueValue(parentNode.childIds || [], node.id);
|
|
parentNode.dependsOn = appendUniqueValue(
|
|
parentNode.dependsOn || [],
|
|
node.id,
|
|
);
|
|
}
|
|
|
|
return graph;
|
|
}
|
|
|
|
function computeParentProgressState(graph, parentNode) {
|
|
const childUnknowns = findDirectChildUnknowns(graph, parentNode.id);
|
|
const resolvedChildren = childUnknowns.filter(
|
|
(child) => child.status === "resolved",
|
|
);
|
|
const progressedChildren = childUnknowns.filter((child) =>
|
|
["resolved", "provisional"].includes(child.status),
|
|
);
|
|
const contradictoryChildren = childUnknowns.filter(
|
|
(child) => child.status === "contradicted",
|
|
);
|
|
const totalChildren = childUnknowns.length;
|
|
const resolvedCount = resolvedChildren.length;
|
|
const unresolvedCount = childUnknowns.filter(
|
|
(child) => child.status !== "resolved",
|
|
).length;
|
|
const beforeAssessment = getNodeConfidenceAssessment(parentNode);
|
|
const interactions = evaluateBranchInteractions({ parentNode, graph });
|
|
const corroborationCount =
|
|
interactions.interactionSummary.corroboratingBranchCount;
|
|
const duplicateEvidenceCount =
|
|
interactions.interactionSummary.duplicateEvidenceCount;
|
|
const conflictingBranchCount =
|
|
interactions.interactionSummary.conflictingBranchCount;
|
|
|
|
if (totalChildren === 0) {
|
|
const nextAssessment = {
|
|
evidenceConfidence: beforeAssessment.evidenceConfidence,
|
|
completenessStatus: beforeAssessment.completenessStatus,
|
|
conclusionConfidence: beforeAssessment.conclusionConfidence,
|
|
};
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: parentNode.status,
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: parentNode.status === "resolved",
|
|
confidenceCapReason: "no_child_structure",
|
|
reason: "Parent has no child unknowns to aggregate.",
|
|
};
|
|
}
|
|
|
|
let nextAssessment;
|
|
let confidenceCapReason;
|
|
|
|
if (contradictoryChildren.length > 0) {
|
|
nextAssessment = {
|
|
evidenceConfidence: resolvedCount > 0 ? "medium" : "low",
|
|
completenessStatus: resolvedCount === 0 ? "empty" : "partial",
|
|
conclusionConfidence: "low",
|
|
};
|
|
confidenceCapReason = "contradictory_direct_children";
|
|
} else if (resolvedCount === 0) {
|
|
nextAssessment = {
|
|
evidenceConfidence: "low",
|
|
completenessStatus: "empty",
|
|
conclusionConfidence: "low",
|
|
};
|
|
confidenceCapReason = "no_resolved_direct_children";
|
|
} else if (resolvedCount < totalChildren) {
|
|
nextAssessment = {
|
|
evidenceConfidence: corroborationCount > 0 ? "high" : "medium",
|
|
completenessStatus: "partial",
|
|
conclusionConfidence: "medium",
|
|
};
|
|
confidenceCapReason =
|
|
conflictingBranchCount > 0
|
|
? "conflicting_branches_cap_conclusion"
|
|
: duplicateEvidenceCount > 0
|
|
? "duplicate_evidence_no_extra_confidence"
|
|
: corroborationCount > 0
|
|
? "independent_corroboration_with_incomplete_parent"
|
|
: "unresolved_direct_children_cap_conclusion";
|
|
} else {
|
|
nextAssessment = {
|
|
evidenceConfidence: "high",
|
|
completenessStatus: "complete",
|
|
conclusionConfidence: conflictingBranchCount > 0 ? "low" : "high",
|
|
};
|
|
confidenceCapReason =
|
|
conflictingBranchCount > 0
|
|
? "conflicting_branches_cap_conclusion"
|
|
: duplicateEvidenceCount > 0
|
|
? "duplicate_evidence_no_extra_confidence"
|
|
: corroborationCount > 0
|
|
? "independent_corroboration_supported_conclusion"
|
|
: null;
|
|
}
|
|
|
|
if (resolvedChildren.length === totalChildren) {
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: "resolved",
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: true,
|
|
resolvedDirectChildren: resolvedCount,
|
|
unresolvedDirectChildren: unresolvedCount,
|
|
contradictoryDirectChildren: contradictoryChildren.length,
|
|
branchInteractions: interactions,
|
|
confidenceCapReason,
|
|
reason:
|
|
"All direct child unknowns are resolved, so the parent can now resolve deterministically.",
|
|
};
|
|
}
|
|
|
|
if (progressedChildren.length > 0) {
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: "provisional",
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: false,
|
|
resolvedDirectChildren: resolvedCount,
|
|
unresolvedDirectChildren: unresolvedCount,
|
|
contradictoryDirectChildren: contradictoryChildren.length,
|
|
branchInteractions: interactions,
|
|
confidenceCapReason,
|
|
reason:
|
|
"At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: parentNode.status,
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: parentNode.status === "resolved",
|
|
resolvedDirectChildren: resolvedCount,
|
|
unresolvedDirectChildren: unresolvedCount,
|
|
contradictoryDirectChildren: contradictoryChildren.length,
|
|
branchInteractions: interactions,
|
|
confidenceCapReason,
|
|
reason: "No direct child progress exists yet for the parent.",
|
|
};
|
|
}
|
|
|
|
export function propagateResolvedChildEvidence({
|
|
updatedSituationGraph,
|
|
proposalSnapshot,
|
|
}) {
|
|
const resolvedChildNodes = (updatedSituationGraph.nodes || []).filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
node.parentId &&
|
|
proposalSnapshot.resolvedUnknownNodeIds.includes(node.id),
|
|
);
|
|
|
|
if (resolvedChildNodes.length === 0) {
|
|
return {
|
|
graph: updatedSituationGraph,
|
|
proposalSnapshot,
|
|
propagationPerformed: false,
|
|
resolvedChildNodeId: null,
|
|
parentNodeId: null,
|
|
parentStatusBefore: null,
|
|
parentStatusAfter: null,
|
|
parentConfidenceBefore: null,
|
|
parentConfidenceAfter: null,
|
|
evidenceConfidenceBefore: null,
|
|
evidenceConfidenceAfter: null,
|
|
completenessBefore: null,
|
|
completenessAfter: null,
|
|
conclusionConfidenceBefore: null,
|
|
conclusionConfidenceAfter: null,
|
|
resolvedDirectChildren: 0,
|
|
unresolvedDirectChildren: 0,
|
|
contradictoryDirectChildren: 0,
|
|
confidenceCapReason: null,
|
|
ancestorPropagationStoppedReason: "no_resolved_child_propagation_needed",
|
|
affectedAncestorIds: [],
|
|
nextSelectedSibling: null,
|
|
parentResolved: false,
|
|
reason: "No resolved decomposition child required upward propagation.",
|
|
};
|
|
}
|
|
|
|
const graph = cloneJsonSafe(updatedSituationGraph);
|
|
syncParentChildReferences(graph);
|
|
const propagationEvents = [];
|
|
const affectedAncestorIds = new Set();
|
|
let ancestorPropagationStoppedReason = "no_ancestor_state_changed";
|
|
|
|
for (const resolvedChildNode of resolvedChildNodes) {
|
|
const liveChildNode = graph.nodes.find(
|
|
(node) => node.id === resolvedChildNode.id,
|
|
);
|
|
if (!liveChildNode) continue;
|
|
|
|
liveChildNode.evidenceIds = appendUniqueValue(
|
|
liveChildNode.evidenceIds || [],
|
|
buildPropagationEvidenceId(liveChildNode.id),
|
|
);
|
|
|
|
const ancestorChain = buildAncestorChain(graph, liveChildNode);
|
|
for (const ancestorNode of ancestorChain) {
|
|
const beforeStatus = ancestorNode.status;
|
|
const beforeConfidence = ancestorNode.confidence;
|
|
const beforeAssessment = getNodeConfidenceAssessment(ancestorNode);
|
|
const progressState = computeParentProgressState(graph, ancestorNode);
|
|
|
|
ancestorNode.status = progressState.nextStatus;
|
|
ancestorNode.confidence = progressState.nextConfidence;
|
|
ancestorNode.confidenceAssessment =
|
|
progressState.nextConfidenceAssessment;
|
|
|
|
if (
|
|
beforeStatus === progressState.nextStatus &&
|
|
beforeConfidence === progressState.nextConfidence &&
|
|
JSON.stringify(beforeAssessment) ===
|
|
JSON.stringify(progressState.nextConfidenceAssessment)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
ancestorPropagationStoppedReason = "ancestor_state_changed";
|
|
|
|
if (progressState.parentResolved) {
|
|
ensureResolvedUnknownId(proposalSnapshot, ancestorNode.id);
|
|
}
|
|
|
|
upsertProposalNodeUpdate(proposalSnapshot, {
|
|
nodeId: ancestorNode.id,
|
|
previousStatus: beforeStatus,
|
|
newStatus: progressState.nextStatus,
|
|
previousValue: ancestorNode.value ?? null,
|
|
newValue: ancestorNode.value ?? null,
|
|
reason: progressState.reason,
|
|
});
|
|
|
|
affectedAncestorIds.add(ancestorNode.id);
|
|
propagationEvents.push({
|
|
resolvedChildNodeId: liveChildNode.id,
|
|
parentNodeId: ancestorNode.id,
|
|
parentStatusBefore: beforeStatus,
|
|
parentStatusAfter: progressState.nextStatus,
|
|
parentConfidenceBefore: beforeConfidence,
|
|
parentConfidenceAfter: progressState.nextConfidence,
|
|
evidenceConfidenceBefore: beforeAssessment.evidenceConfidence,
|
|
evidenceConfidenceAfter:
|
|
progressState.nextConfidenceAssessment.evidenceConfidence,
|
|
completenessBefore: beforeAssessment.completenessStatus,
|
|
completenessAfter:
|
|
progressState.nextConfidenceAssessment.completenessStatus,
|
|
conclusionConfidenceBefore: beforeAssessment.conclusionConfidence,
|
|
conclusionConfidenceAfter:
|
|
progressState.nextConfidenceAssessment.conclusionConfidence,
|
|
resolvedDirectChildren: progressState.resolvedDirectChildren,
|
|
unresolvedDirectChildren: progressState.unresolvedDirectChildren,
|
|
contradictoryDirectChildren: progressState.contradictoryDirectChildren,
|
|
corroboratingBranchCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.corroboratingBranchCount,
|
|
conflictingBranchCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.conflictingBranchCount,
|
|
duplicateEvidenceCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.duplicateEvidenceCount,
|
|
independentBranchCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.independentBranchCount,
|
|
interactionSummary: progressState.branchInteractions.interactionSummary,
|
|
confidenceCapReason: progressState.confidenceCapReason,
|
|
parentResolved: progressState.parentResolved,
|
|
reason: progressState.reason,
|
|
});
|
|
}
|
|
}
|
|
|
|
graph.resolvedNodeIds = [
|
|
...new Set([
|
|
...graph.resolvedNodeIds,
|
|
...proposalSnapshot.resolvedUnknownNodeIds,
|
|
]),
|
|
];
|
|
|
|
const siblingSelection = selectActiveUnknownCandidate(
|
|
graph,
|
|
graph.resolvedNodeIds,
|
|
);
|
|
const firstEvent = propagationEvents[0] ?? null;
|
|
|
|
return {
|
|
graph,
|
|
proposalSnapshot,
|
|
propagationPerformed: propagationEvents.length > 0,
|
|
resolvedChildNodeId: firstEvent?.resolvedChildNodeId ?? null,
|
|
parentNodeId: firstEvent?.parentNodeId ?? null,
|
|
parentStatusBefore: firstEvent?.parentStatusBefore ?? null,
|
|
parentStatusAfter: firstEvent?.parentStatusAfter ?? null,
|
|
parentConfidenceBefore: firstEvent?.parentConfidenceBefore ?? null,
|
|
parentConfidenceAfter: firstEvent?.parentConfidenceAfter ?? null,
|
|
evidenceConfidenceBefore: firstEvent?.evidenceConfidenceBefore ?? null,
|
|
evidenceConfidenceAfter: firstEvent?.evidenceConfidenceAfter ?? null,
|
|
completenessBefore: firstEvent?.completenessBefore ?? null,
|
|
completenessAfter: firstEvent?.completenessAfter ?? null,
|
|
conclusionConfidenceBefore: firstEvent?.conclusionConfidenceBefore ?? null,
|
|
conclusionConfidenceAfter: firstEvent?.conclusionConfidenceAfter ?? null,
|
|
resolvedDirectChildren: firstEvent?.resolvedDirectChildren ?? 0,
|
|
unresolvedDirectChildren: firstEvent?.unresolvedDirectChildren ?? 0,
|
|
contradictoryDirectChildren: firstEvent?.contradictoryDirectChildren ?? 0,
|
|
corroboratingBranchCount: firstEvent?.corroboratingBranchCount ?? 0,
|
|
conflictingBranchCount: firstEvent?.conflictingBranchCount ?? 0,
|
|
duplicateEvidenceCount: firstEvent?.duplicateEvidenceCount ?? 0,
|
|
independentBranchCount: firstEvent?.independentBranchCount ?? 0,
|
|
interactionSummary: firstEvent?.interactionSummary ?? null,
|
|
confidenceCapReason: firstEvent?.confidenceCapReason ?? null,
|
|
ancestorPropagationStoppedReason,
|
|
affectedAncestorIds: [...affectedAncestorIds],
|
|
nextSelectedSibling:
|
|
siblingSelection?.status === "selected" ? siblingSelection.nodeId : null,
|
|
parentResolved: firstEvent?.parentResolved ?? false,
|
|
reason:
|
|
firstEvent?.reason ??
|
|
"Resolved child evidence propagated upward through the decomposition chain.",
|
|
};
|
|
}
|
|
|
|
function buildEmergentReasoningUnknownLabel(graph) {
|
|
const central = String(graph?.centralStatement || "these observations")
|
|
.trim()
|
|
.replace(/[.?!:;]+$/g, "");
|
|
return `Explanation for why ${central}`;
|
|
}
|
|
|
|
function findEquivalentEmergentUnknown(graph, label, description) {
|
|
const targetId = makeNodeId(label);
|
|
const targetTexts = [normaliseText(label), normaliseText(description)].filter(
|
|
Boolean,
|
|
);
|
|
|
|
return (graph.nodes || []).find((node) => {
|
|
if (
|
|
node.kind !== "unknown" ||
|
|
(graph.resolvedNodeIds || []).includes(node.id)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (node.id === targetId) {
|
|
return true;
|
|
}
|
|
|
|
const nodeTexts = [
|
|
normaliseText(node.label),
|
|
normaliseText(node.description),
|
|
].filter(Boolean);
|
|
|
|
return targetTexts.some((text) => nodeTexts.includes(text));
|
|
});
|
|
}
|
|
|
|
function buildEmergentReasoningUnknown(graph, relationshipAssessment) {
|
|
if (!relationshipAssessment?.relationshipAssessed) {
|
|
return null;
|
|
}
|
|
|
|
if (!relationshipAssessment.questionRequired) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
![
|
|
"potentially_related",
|
|
"insufficient_information",
|
|
"contradictory",
|
|
].includes(relationshipAssessment.relationshipStatus)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const label = buildEmergentReasoningUnknownLabel(graph);
|
|
const description =
|
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.";
|
|
const existingNode = findEquivalentEmergentUnknown(graph, label, description);
|
|
if (existingNode) {
|
|
return {
|
|
created: false,
|
|
node: existingNode,
|
|
edges: [],
|
|
reason:
|
|
"Reused an existing unresolved reasoning unknown for the next investigation stage.",
|
|
};
|
|
}
|
|
|
|
const observationNodes = (graph.nodes || []).filter(
|
|
(node) => node.kind === "observation" && node.status === "supported",
|
|
);
|
|
const relationshipNode = (graph.nodes || []).find(
|
|
(node) => node.kind === "relationship" && node.status === "supported",
|
|
);
|
|
const nodeId = makeNodeId(label);
|
|
const relatedNodeIds = relationshipNode
|
|
? [relationshipNode.id]
|
|
: observationNodes.slice(0, 2).map((node) => node.id);
|
|
|
|
if (relatedNodeIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const node = {
|
|
id: nodeId,
|
|
label,
|
|
description,
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: relatedNodeIds,
|
|
affects: [],
|
|
parentId: relationshipNode?.id ?? null,
|
|
childIds: [],
|
|
};
|
|
|
|
const edges = relatedNodeIds.map((relatedNodeId) => ({
|
|
id: `e-${relatedNodeId.slice(0, 6)}-${nodeId.slice(0, 6)}`,
|
|
fromNodeId: relatedNodeId,
|
|
toNodeId: nodeId,
|
|
relationship:
|
|
relationshipNode?.id === relatedNodeId ? "depends_on" : "other",
|
|
confidence: "medium",
|
|
description:
|
|
"This unresolved explanation arises from the now-assessed relationship between the observations.",
|
|
}));
|
|
|
|
return {
|
|
created: true,
|
|
node,
|
|
edges,
|
|
reason:
|
|
"Created a new unresolved reasoning unknown so the next justified question is backed by the graph.",
|
|
};
|
|
}
|
|
|
|
function stripTrailingPunctuation(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.replace(/[.?!:;]+$/g, "")
|
|
.trim();
|
|
}
|
|
|
|
function collectSupportedObservations(graph) {
|
|
return (graph.nodes || []).filter(
|
|
(node) => node.kind === "observation" && node.status === "supported",
|
|
);
|
|
}
|
|
|
|
function detectObservationConcept(text) {
|
|
const normalised = normaliseText(text);
|
|
const concepts = [
|
|
["revenue", /\brevenue\b/],
|
|
["cash", /\bcash\b/],
|
|
["customer satisfaction", /\bsatisfaction\b/],
|
|
["complaints", /\bcomplaints?\b/],
|
|
["delivery time", /\bdelivery time\b|\bdelivery\b/],
|
|
["cancellations", /\bcancellations?\b/],
|
|
["traffic", /\btraffic\b/],
|
|
["sales", /\bsales\b/],
|
|
["production", /\bproduction\b|\boutput\b/],
|
|
["defects", /\bdefects?\b/],
|
|
["quality", /\bquality\b/],
|
|
];
|
|
|
|
for (const [label, pattern] of concepts) {
|
|
if (pattern.test(normalised)) return label;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildDecompositionContext(graph) {
|
|
const observations = collectSupportedObservations(graph);
|
|
const firstObservation = observations[0] ?? null;
|
|
const secondObservation = observations[1] ?? null;
|
|
const firstConcept = detectObservationConcept(
|
|
`${firstObservation?.label || ""} ${firstObservation?.description || ""}`,
|
|
);
|
|
const secondConcept = detectObservationConcept(
|
|
`${secondObservation?.label || ""} ${secondObservation?.description || ""}`,
|
|
);
|
|
|
|
return {
|
|
centralStatement: stripTrailingPunctuation(graph.centralStatement),
|
|
firstConcept: firstConcept || "the first signal",
|
|
secondConcept: secondConcept || "the second signal",
|
|
firstObservationLabel: stripTrailingPunctuation(
|
|
firstObservation?.label || "",
|
|
),
|
|
secondObservationLabel: stripTrailingPunctuation(
|
|
secondObservation?.label || "",
|
|
),
|
|
};
|
|
}
|
|
|
|
export const MAX_DECOMPOSITION_DEPTH = 2;
|
|
|
|
function splitSemanticTokens(value) {
|
|
return normaliseText(value)
|
|
.split(" ")
|
|
.filter((token) => token.length > 2);
|
|
}
|
|
|
|
function buildSemanticSignature(node) {
|
|
return normaliseText(`${node?.label || ""} ${node?.description || ""}`);
|
|
}
|
|
|
|
function calculateTokenOverlapRatio(aTokens, bTokens) {
|
|
const a = new Set(aTokens);
|
|
const b = new Set(bTokens);
|
|
const intersection = [...a].filter((token) => b.has(token)).length;
|
|
const largest = Math.max(a.size, b.size, 1);
|
|
return intersection / largest;
|
|
}
|
|
|
|
function detectCompoundSignals(text) {
|
|
const signals = [];
|
|
|
|
if (/\b(and|or)\b/.test(text)) {
|
|
if (
|
|
/\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b[^.]{0,30}\b(and|or)\b[^.]{0,30}\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b/.test(
|
|
text,
|
|
)
|
|
) {
|
|
signals.push("conjoined_distinct_concepts");
|
|
}
|
|
}
|
|
|
|
if (/\b[a-z]+\s*\/\s*[a-z]+\b/.test(text)) {
|
|
signals.push("slash_separated_categories");
|
|
}
|
|
|
|
if (/,[^,]{0,20},/.test(text) || /,\s*[^,]+\s+or\s+[^,]+/.test(text)) {
|
|
signals.push("comma_separated_category_list");
|
|
}
|
|
|
|
if (/\btiming or measurement basis\b/.test(text)) {
|
|
signals.push("timing_or_measurement_basis");
|
|
}
|
|
|
|
return [...new Set(signals)];
|
|
}
|
|
|
|
function isDirectlyAnswerableChildText(text) {
|
|
return !/\b(explanation for why|possible causes|possible reasons|what changed|difference between|divergence|moved differently|factors behind|factors affecting)\b/.test(
|
|
text,
|
|
);
|
|
}
|
|
|
|
function buildRejectedChildRecord(childNode, reasons, compoundSignals) {
|
|
return {
|
|
nodeId: childNode.id,
|
|
label: childNode.label,
|
|
reasons,
|
|
compoundSignals,
|
|
};
|
|
}
|
|
|
|
function mergeUniqueRecords(existing = [], incoming = [], key = "nodeId") {
|
|
const merged = new Map((existing || []).map((item) => [item[key], item]));
|
|
for (const item of incoming || []) {
|
|
merged.set(item[key], item);
|
|
}
|
|
return [...merged.values()];
|
|
}
|
|
|
|
function cloneNode(node) {
|
|
return JSON.parse(JSON.stringify(node));
|
|
}
|
|
|
|
export function assessChildUnknownQuality({
|
|
parentNode,
|
|
childNode,
|
|
siblingNodes,
|
|
graph,
|
|
}) {
|
|
const parentSignature = buildSemanticSignature(parentNode);
|
|
const childSignature = buildSemanticSignature(childNode);
|
|
const parentTokens = splitSemanticTokens(parentSignature);
|
|
const childTokens = splitSemanticTokens(childSignature);
|
|
const compoundSignals = detectCompoundSignals(childSignature);
|
|
const duplicateSiblingIds = (siblingNodes || [])
|
|
.filter((sibling) => sibling.id !== childNode.id)
|
|
.filter((sibling) => buildSemanticSignature(sibling) === childSignature)
|
|
.map((sibling) => sibling.id);
|
|
const reasons = [];
|
|
|
|
const narrowerThanParent =
|
|
childSignature !== parentSignature &&
|
|
(childTokens.length < parentTokens.length ||
|
|
calculateTokenOverlapRatio(parentTokens, childTokens) < 0.8);
|
|
|
|
const directlyAnswerable =
|
|
isDirectlyAnswerableChildText(childSignature) &&
|
|
compoundSignals.length === 0;
|
|
|
|
const independent =
|
|
duplicateSiblingIds.length === 0 && compoundSignals.length === 0;
|
|
const atomic = narrowerThanParent && directlyAnswerable && independent;
|
|
|
|
if (!narrowerThanParent) {
|
|
reasons.push("not_narrower_than_parent");
|
|
}
|
|
if (!directlyAnswerable) {
|
|
reasons.push("not_directly_answerable");
|
|
}
|
|
if (compoundSignals.length > 0) {
|
|
reasons.push("compound_child");
|
|
}
|
|
if (duplicateSiblingIds.length > 0) {
|
|
reasons.push("duplicate_sibling");
|
|
}
|
|
if ((graph?.resolvedNodeIds || []).includes(childNode.id)) {
|
|
reasons.push("already_resolved");
|
|
}
|
|
|
|
return {
|
|
valid: reasons.length === 0,
|
|
atomic,
|
|
directlyAnswerable,
|
|
independent,
|
|
narrowerThanParent,
|
|
compoundSignals,
|
|
duplicateSiblingIds,
|
|
reasons,
|
|
};
|
|
}
|
|
|
|
function buildDecompositionChildId(parentNodeId, label) {
|
|
return makeNodeId(`${parentNodeId}:${label}`);
|
|
}
|
|
|
|
function findEquivalentDecompositionChild(
|
|
graph,
|
|
parentNodeId,
|
|
label,
|
|
description,
|
|
) {
|
|
const targetId = buildDecompositionChildId(parentNodeId, label);
|
|
const targetTexts = [normaliseText(label), normaliseText(description)].filter(
|
|
Boolean,
|
|
);
|
|
|
|
return (graph.nodes || []).find((node) => {
|
|
if (
|
|
node.kind !== "unknown" ||
|
|
node.parentId !== parentNodeId ||
|
|
(graph.resolvedNodeIds || []).includes(node.id)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (node.id === targetId) {
|
|
return true;
|
|
}
|
|
|
|
const nodeTexts = [
|
|
normaliseText(node.label),
|
|
normaliseText(node.description),
|
|
].filter(Boolean);
|
|
|
|
return targetTexts.some((text) => nodeTexts.includes(text));
|
|
});
|
|
}
|
|
|
|
function describeObservationFocus(context, which) {
|
|
const concept =
|
|
which === "first" ? context.firstConcept : context.secondConcept;
|
|
const label =
|
|
which === "first"
|
|
? context.firstObservationLabel
|
|
: context.secondObservationLabel;
|
|
|
|
if (concept && !concept.startsWith("the ")) return concept;
|
|
if (label) return label.toLowerCase();
|
|
return which === "first" ? "the first observation" : "the second observation";
|
|
}
|
|
|
|
function buildDecompositionTemplates(parentNode, graph, depth = 0) {
|
|
const 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 evidenceConfidenceBefore = propagationResult.evidenceConfidenceBefore;
|
|
const evidenceConfidenceAfter = propagationResult.evidenceConfidenceAfter;
|
|
const completenessBefore = propagationResult.completenessBefore;
|
|
const completenessAfter = propagationResult.completenessAfter;
|
|
const conclusionConfidenceBefore =
|
|
propagationResult.conclusionConfidenceBefore;
|
|
const conclusionConfidenceAfter = propagationResult.conclusionConfidenceAfter;
|
|
const resolvedDirectChildren = propagationResult.resolvedDirectChildren;
|
|
const unresolvedDirectChildren = propagationResult.unresolvedDirectChildren;
|
|
const contradictoryDirectChildren =
|
|
propagationResult.contradictoryDirectChildren;
|
|
const confidenceCapReason = propagationResult.confidenceCapReason;
|
|
const ancestorPropagationStoppedReason =
|
|
propagationResult.ancestorPropagationStoppedReason;
|
|
const affectedAncestorIds = propagationResult.affectedAncestorIds;
|
|
const nextSelectedSibling = propagationResult.nextSelectedSibling;
|
|
const parentResolved = propagationResult.parentResolved;
|
|
const corroboratingBranchCount = propagationResult.corroboratingBranchCount;
|
|
const conflictingBranchCount = propagationResult.conflictingBranchCount;
|
|
const duplicateEvidenceCount = propagationResult.duplicateEvidenceCount;
|
|
const independentBranchCount = propagationResult.independentBranchCount;
|
|
const interactionSummary = propagationResult.interactionSummary;
|
|
const propagationReason = propagationResult.reason;
|
|
|
|
if (
|
|
deterministicSelection?.status === "selected" &&
|
|
deterministicSelection?.nodeId
|
|
) {
|
|
newActiveUnknownNodeId = deterministicSelection.nodeId;
|
|
} else if (deterministicSelection?.status === "ambiguous") {
|
|
newActiveUnknownNodeId = null;
|
|
}
|
|
|
|
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
|
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
|
|
|
const selectedNode =
|
|
deterministicSelection?.status === "selected" &&
|
|
deterministicSelection?.nodeId
|
|
? updatedSituationGraph.nodes.find(
|
|
(node) => node.id === deterministicSelection.nodeId,
|
|
)
|
|
: null;
|
|
const formulatedQuestion = selectedNode
|
|
? formulateQuestion({
|
|
node: selectedNode,
|
|
graph: updatedSituationGraph,
|
|
context: {
|
|
resolvedValues: validatedProposal.updatedNodes
|
|
.map((update) => update.newValue)
|
|
.filter(
|
|
(value) => typeof value === "string" && value.trim().length > 0,
|
|
),
|
|
selectionState: deterministicSelection,
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const 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,
|
|
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,
|
|
atomicityReason:
|
|
propagationReason ||
|
|
decompositionReason ||
|
|
atomicityAssessment?.reason ||
|
|
null,
|
|
previousActiveUnknownNodeId,
|
|
newActiveUnknownNodeId,
|
|
selectedQuestion: finalSelectedQuestion,
|
|
changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds),
|
|
graphReferenceValidation: resultReferenceValidation,
|
|
previousReasoningState: reasoningResolution.previousReasoningState,
|
|
reasoningState: nextReasoningState,
|
|
};
|
|
}
|