1213 lines
36 KiB
JavaScript
1213 lines
36 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 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",
|
|
};
|
|
}
|
|
|
|
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 buildCompositeUnknownChildren(parentNode, graph) {
|
|
const context = buildDecompositionContext(graph);
|
|
const templates = [
|
|
{
|
|
label: "Timing or measurement basis",
|
|
description: `Need evidence about whether a timing or measurement-basis difference could explain ${context.centralStatement}, because that would change how the observations should be interpreted.`,
|
|
},
|
|
{
|
|
label: `Change affecting ${context.firstConcept} more than ${context.secondConcept}`,
|
|
description: `Need to know whether something changed that affected ${context.firstConcept} more than ${context.secondConcept}, because that could explain ${context.centralStatement}.`,
|
|
},
|
|
{
|
|
label: `Change affecting ${context.secondConcept} more than ${context.firstConcept}`,
|
|
description: `Need to know whether something changed that affected ${context.secondConcept} more than ${context.firstConcept}, because that could explain ${context.centralStatement}.`,
|
|
},
|
|
{
|
|
label: "Mix or segment shift",
|
|
description: `Need to know whether the mix of customers, products, orders, or cases changed, because that could explain ${context.centralStatement}.`,
|
|
},
|
|
{
|
|
label: "One-off event during the period",
|
|
description: `Need to know whether a one-off event or unusual change happened during the period, because that could explain ${context.centralStatement}.`,
|
|
},
|
|
];
|
|
|
|
const childNodes = [];
|
|
const childEdges = [];
|
|
const childNodeIds = [];
|
|
let createdCount = 0;
|
|
|
|
for (const template of templates) {
|
|
const existingNode = findEquivalentDecompositionChild(
|
|
graph,
|
|
parentNode.id,
|
|
template.label,
|
|
template.description,
|
|
);
|
|
const childNode = existingNode || {
|
|
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: [],
|
|
};
|
|
|
|
childNodeIds.push(childNode.id);
|
|
|
|
if (existingNode) {
|
|
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.",
|
|
});
|
|
createdCount += 1;
|
|
}
|
|
|
|
return {
|
|
childNodes,
|
|
childEdges,
|
|
childNodeIds,
|
|
createdCount,
|
|
reason:
|
|
createdCount > 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 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,
|
|
);
|
|
|
|
let atomicityAssessment = null;
|
|
let decompositionPerformed = false;
|
|
let decompositionChildNodeIds = [];
|
|
let decompositionReason = null;
|
|
|
|
const initiallySelectedNode =
|
|
deterministicSelection?.status === "selected" &&
|
|
deterministicSelection?.nodeId
|
|
? updatedSituationGraph.nodes.find(
|
|
(node) => node.id === deterministicSelection.nodeId,
|
|
)
|
|
: null;
|
|
|
|
if (initiallySelectedNode) {
|
|
atomicityAssessment = assessUnknownAtomicity({
|
|
node: initiallySelectedNode,
|
|
graph: updatedSituationGraph,
|
|
});
|
|
|
|
if (atomicityAssessment.atomicity === "composite") {
|
|
const decomposition = buildCompositeUnknownChildren(
|
|
initiallySelectedNode,
|
|
updatedSituationGraph,
|
|
);
|
|
decompositionPerformed = true;
|
|
decompositionChildNodeIds = decomposition.childNodeIds;
|
|
decompositionReason =
|
|
decomposition.reason || atomicityAssessment.reason || null;
|
|
|
|
if (
|
|
decomposition.childNodes.length > 0 ||
|
|
decomposition.childEdges.length > 0
|
|
) {
|
|
proposalSnapshot.addedNodes.push(...decomposition.childNodes);
|
|
proposalSnapshot.addedEdges.push(...decomposition.childEdges);
|
|
|
|
applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
|
|
if (!applied.success) {
|
|
return {
|
|
success: false,
|
|
stage: "application",
|
|
errors: applied.errors,
|
|
};
|
|
}
|
|
|
|
updatedSituationGraph = {
|
|
...graphSnapshot,
|
|
nodes: applied.nodes,
|
|
edges: applied.edges,
|
|
resolvedNodeIds: applied.resolvedNodeIds,
|
|
};
|
|
nextReasoningState = buildReasoningState(
|
|
updatedSituationGraph,
|
|
reasoningResolution.reasoningStateOverride,
|
|
);
|
|
updatedSituationGraph.reasoningState = nextReasoningState;
|
|
}
|
|
|
|
deterministicSelection = selectActiveUnknownCandidate(
|
|
updatedSituationGraph,
|
|
updatedSituationGraph.resolvedNodeIds,
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
decompositionPerformed,
|
|
childUnknownCount: decompositionChildNodeIds.length,
|
|
childNodeIds: decompositionChildNodeIds,
|
|
atomicityReason: decompositionReason || atomicityAssessment?.reason || null,
|
|
previousActiveUnknownNodeId,
|
|
newActiveUnknownNodeId,
|
|
selectedQuestion: finalSelectedQuestion,
|
|
changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds),
|
|
graphReferenceValidation: resultReferenceValidation,
|
|
previousReasoningState: reasoningResolution.previousReasoningState,
|
|
reasoningState: nextReasoningState,
|
|
};
|
|
}
|