4002 lines
126 KiB
JavaScript
4002 lines
126 KiB
JavaScript
import { describeGraph } from "./builder.js";
|
|
import {
|
|
assessUnknownAnswerability,
|
|
assessUnknownAtomicity,
|
|
buildReasoningState,
|
|
classifyObservationRelationship,
|
|
COMPARABILITY_REASONING_NODE_ID,
|
|
formulateQuestion,
|
|
formulateTieResolutionQuestion,
|
|
selectReasoningPattern,
|
|
} from "./question-formulator.js";
|
|
import {
|
|
graphUpdateSchema,
|
|
makeNodeId,
|
|
situationGraphSchema,
|
|
} from "./schema.js";
|
|
import {
|
|
applyGraphUpdate,
|
|
detectDuplicateNodeIds,
|
|
findAffectedNodes,
|
|
scoreUnknownCandidate,
|
|
selectActiveUnknownCandidate,
|
|
validateGraphReferences,
|
|
validateGraphUpdate,
|
|
} from "./utils.js";
|
|
|
|
function cloneJsonSafe(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function zodIssuesToErrors(error) {
|
|
return (
|
|
error?.issues?.map((issue) => {
|
|
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
|
|
return `${path}${issue.message}`;
|
|
}) ?? ["Validation failed"]
|
|
);
|
|
}
|
|
|
|
function collectDuplicateEdgeIds(edges) {
|
|
const counts = new Map();
|
|
|
|
for (const edge of edges) {
|
|
counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1);
|
|
}
|
|
|
|
return [...counts.entries()]
|
|
.filter(([, count]) => count > 1)
|
|
.map(([edgeId, count]) => ({ edgeId, count }));
|
|
}
|
|
|
|
function normaliseText(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function buildNodeById(graph, addedNodes = []) {
|
|
return new Map(
|
|
[...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
|
|
);
|
|
}
|
|
|
|
function isCompoundQuestion(question) {
|
|
if (typeof question !== "string") return false;
|
|
const trimmed = question.trim();
|
|
if (!trimmed) return false;
|
|
|
|
const questionMarks = (trimmed.match(/\?/g) || []).length;
|
|
if (questionMarks > 1) return true;
|
|
if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
|
|
if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
function validateAddedUnknowns(graph, proposal, answerMeaning) {
|
|
const errors = [];
|
|
const addedUnknowns = proposal.addedNodes.filter(
|
|
(node) => node.kind === "unknown",
|
|
);
|
|
|
|
const userSupportedMeaningText = answerMeaning?.userSupportedMeaning;
|
|
|
|
function hasNodeLevelUserSupport(unknownNode) {
|
|
if (!userSupportedMeaningText) return false;
|
|
|
|
const unknownText = normaliseSemanticText(
|
|
[normaliseText(unknownNode.label), normaliseText(unknownNode.description)]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
);
|
|
|
|
const overlapRatio = semanticOverlapRatio(userSupportedMeaningText, unknownText);
|
|
const overlappingTokens = semanticContentTokens(userSupportedMeaningText).filter(
|
|
(token) => semanticContentTokens(unknownText).includes(token),
|
|
).length;
|
|
|
|
return overlapRatio >= 0.4 || overlappingTokens >= 3;
|
|
}
|
|
|
|
if (addedUnknowns.length > 3) {
|
|
errors.push(
|
|
`Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`,
|
|
);
|
|
}
|
|
|
|
const unresolvedExistingUnknowns = graph.nodes.filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
!proposal.resolvedUnknownNodeIds.includes(node.id),
|
|
);
|
|
const seenAddedUnknownMeanings = new Map();
|
|
const answerDerivedNodeIds = new Set([
|
|
...proposal.updatedNodes.map((update) => update.nodeId),
|
|
...proposal.resolvedUnknownNodeIds,
|
|
...proposal.addedNodes
|
|
.filter((node) => node.kind !== "unknown")
|
|
.map((node) => node.id),
|
|
]);
|
|
const proposalNodeById = buildNodeById(graph, proposal.addedNodes);
|
|
|
|
function hasExplicitNodeReference(fromNode, toNodeId) {
|
|
if (!fromNode || !toNodeId) return false;
|
|
|
|
return (
|
|
fromNode.parentId === toNodeId ||
|
|
fromNode.dependsOn.includes(toNodeId) ||
|
|
fromNode.affects.includes(toNodeId) ||
|
|
fromNode.childIds.includes(toNodeId)
|
|
);
|
|
}
|
|
|
|
function hasExplicitAnswerDerivedRelationship(unknownNode) {
|
|
const connectedEdge = proposal.addedEdges.find(
|
|
(edge) =>
|
|
(edge.fromNodeId === unknownNode.id &&
|
|
answerDerivedNodeIds.has(edge.toNodeId)) ||
|
|
(edge.toNodeId === unknownNode.id &&
|
|
answerDerivedNodeIds.has(edge.fromNodeId)),
|
|
);
|
|
|
|
if (connectedEdge) {
|
|
return true;
|
|
}
|
|
|
|
for (const answerDerivedNodeId of answerDerivedNodeIds) {
|
|
const answerDerivedNode = proposalNodeById.get(answerDerivedNodeId);
|
|
|
|
if (
|
|
hasExplicitNodeReference(unknownNode, answerDerivedNodeId) ||
|
|
hasExplicitNodeReference(answerDerivedNode, unknownNode.id)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
for (const unknownNode of addedUnknowns) {
|
|
const meaningKeys = [
|
|
normaliseText(unknownNode.label),
|
|
normaliseText(unknownNode.description),
|
|
].filter(Boolean);
|
|
|
|
for (const meaningKey of meaningKeys) {
|
|
if (seenAddedUnknownMeanings.has(meaningKey)) {
|
|
errors.push(
|
|
`Proposal adds duplicate unknown meaning: "${unknownNode.label}"`,
|
|
);
|
|
break;
|
|
}
|
|
seenAddedUnknownMeanings.set(meaningKey, unknownNode.id);
|
|
}
|
|
|
|
for (const existingUnknown of unresolvedExistingUnknowns) {
|
|
const existingMeaningKeys = [
|
|
normaliseText(existingUnknown.label),
|
|
normaliseText(existingUnknown.description),
|
|
].filter(Boolean);
|
|
if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) {
|
|
errors.push(
|
|
`Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (
|
|
unknownNode.description.trim() === unknownNode.label.trim() ||
|
|
!/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test(
|
|
unknownNode.description,
|
|
)
|
|
) {
|
|
errors.push(
|
|
`New unknown must include why it matters in its description: "${unknownNode.id}"`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
!hasNodeLevelUserSupport(unknownNode) &&
|
|
!hasExplicitAnswerDerivedRelationship(unknownNode)
|
|
) {
|
|
errors.push(
|
|
`New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateSelectedQuestion(graph, proposal) {
|
|
const errors = [];
|
|
const selectedQuestion = proposal.selectedQuestion;
|
|
const nodeById = buildNodeById(graph, proposal.addedNodes);
|
|
|
|
if (selectedQuestion == null) {
|
|
return { errors, selectedQuestionNodeId: null };
|
|
}
|
|
|
|
const node = nodeById.get(selectedQuestion.nodeId);
|
|
if (!node) {
|
|
errors.push(
|
|
`selectedQuestion references missing node: "${selectedQuestion.nodeId}"`,
|
|
);
|
|
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
|
|
}
|
|
|
|
if (node.kind !== "unknown") {
|
|
errors.push(
|
|
`selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`,
|
|
);
|
|
}
|
|
|
|
const resolvesNode = proposal.resolvedUnknownNodeIds.includes(
|
|
selectedQuestion.nodeId,
|
|
);
|
|
const updatedStatus = proposal.updatedNodes.find(
|
|
(update) => update.nodeId === selectedQuestion.nodeId,
|
|
)?.newStatus;
|
|
const effectiveStatus = updatedStatus ?? node.status;
|
|
|
|
if (resolvesNode || effectiveStatus === "resolved") {
|
|
errors.push(
|
|
`selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
graph.activeUnknownNodeId &&
|
|
proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) &&
|
|
selectedQuestion.nodeId === graph.activeUnknownNodeId
|
|
) {
|
|
errors.push(
|
|
`selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`,
|
|
);
|
|
}
|
|
|
|
if (isCompoundQuestion(selectedQuestion.question)) {
|
|
errors.push("selectedQuestion must be a single non-compound question");
|
|
}
|
|
|
|
const resolvedNodeIds = [
|
|
...(graph.resolvedNodeIds || []),
|
|
...(proposal.resolvedUnknownNodeIds || []),
|
|
];
|
|
const candidateScore = scoreUnknownCandidate(
|
|
{
|
|
...graph,
|
|
nodes: [...graph.nodes, ...(proposal.addedNodes || [])],
|
|
edges: [...graph.edges, ...(proposal.addedEdges || [])],
|
|
},
|
|
node,
|
|
resolvedNodeIds,
|
|
);
|
|
|
|
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
|
|
}
|
|
|
|
function validateQuestionSelectionRequirement(graph, proposal) {
|
|
const addedConsequentialUnknowns = proposal.addedNodes.filter(
|
|
(node) => node.kind === "unknown" && node.status !== "resolved",
|
|
);
|
|
|
|
if (
|
|
proposal.selectedQuestion == null &&
|
|
addedConsequentialUnknowns.length > 0
|
|
) {
|
|
return [
|
|
"selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown",
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function buildResolvedUnknownUpdate(node) {
|
|
return {
|
|
nodeId: node.id,
|
|
previousStatus: node.status ?? null,
|
|
newStatus: "resolved",
|
|
previousValue: node.value ?? null,
|
|
newValue: node.value ?? null,
|
|
reason:
|
|
"Resolved because the proposal explicitly marked this unknown as resolved.",
|
|
};
|
|
}
|
|
|
|
function reconcileResolutionSemantics(graph, proposal) {
|
|
const nextProposal = cloneJsonSafe(proposal);
|
|
const errors = [];
|
|
const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
const updatedNodeById = new Map(
|
|
nextProposal.updatedNodes.map((nodeUpdate) => [
|
|
nodeUpdate.nodeId,
|
|
nodeUpdate,
|
|
]),
|
|
);
|
|
|
|
for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) {
|
|
const existingNode = graphNodeById.get(resolvedUnknownNodeId);
|
|
|
|
if (!existingNode) {
|
|
errors.push(
|
|
`Resolved unknown must reference an existing node: "${resolvedUnknownNodeId}"`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (existingNode.kind !== "unknown") {
|
|
errors.push(
|
|
`Resolved unknown must reference an existing unknown node: "${resolvedUnknownNodeId}"`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId);
|
|
if (!existingUpdate) {
|
|
const syntheticUpdate = buildResolvedUnknownUpdate(existingNode);
|
|
nextProposal.updatedNodes.push(syntheticUpdate);
|
|
updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate);
|
|
continue;
|
|
}
|
|
|
|
if (existingUpdate.newStatus !== "resolved") {
|
|
existingUpdate.newStatus = "resolved";
|
|
if (existingUpdate.previousStatus == null) {
|
|
existingUpdate.previousStatus = existingNode.status ?? null;
|
|
}
|
|
if (existingUpdate.previousValue === undefined) {
|
|
existingUpdate.previousValue = existingNode.value ?? null;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const update of nextProposal.updatedNodes) {
|
|
const existingNode = graphNodeById.get(update.nodeId);
|
|
if (
|
|
existingNode?.kind === "unknown" &&
|
|
update.newStatus === "resolved" &&
|
|
!nextProposal.resolvedUnknownNodeIds.includes(update.nodeId)
|
|
) {
|
|
errors.push(
|
|
`Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
proposal: nextProposal,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
function validateSemanticDuplicateUnknowns(graph, proposal) {
|
|
const errors = [];
|
|
const unresolvedUnknowns = graph.nodes.filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
!proposal.resolvedUnknownNodeIds.includes(node.id),
|
|
);
|
|
|
|
for (const addedNode of proposal.addedNodes) {
|
|
const addedTexts = [
|
|
normaliseText(addedNode.label),
|
|
normaliseText(addedNode.description),
|
|
].filter(Boolean);
|
|
|
|
for (const unresolvedUnknown of unresolvedUnknowns) {
|
|
const unresolvedTexts = [
|
|
normaliseText(unresolvedUnknown.label),
|
|
normaliseText(unresolvedUnknown.description),
|
|
].filter(Boolean);
|
|
|
|
const duplicatesMeaning = addedTexts.some((text) =>
|
|
unresolvedTexts.includes(text),
|
|
);
|
|
|
|
if (!duplicatesMeaning) continue;
|
|
|
|
const linkedToUnknown = proposal.addedEdges.some(
|
|
(edge) =>
|
|
(edge.fromNodeId === addedNode.id &&
|
|
edge.toNodeId === unresolvedUnknown.id) ||
|
|
(edge.toNodeId === addedNode.id &&
|
|
edge.fromNodeId === unresolvedUnknown.id),
|
|
);
|
|
|
|
const updatedUnknown = proposal.updatedNodes.some(
|
|
(update) => update.nodeId === unresolvedUnknown.id,
|
|
);
|
|
|
|
if (!linkedToUnknown && !updatedUnknown) {
|
|
errors.push(
|
|
`Proposal adds a node duplicating unresolved unknown meaning without linking or resolving it: "${unresolvedUnknown.id}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function buildAffectedNodeIds(graph, proposal) {
|
|
const affected = new Set(proposal.affectedNodeIds ?? []);
|
|
|
|
for (const update of proposal.updatedNodes ?? []) {
|
|
affected.add(update.nodeId);
|
|
for (const nodeId of findAffectedNodes(graph, update.nodeId)) {
|
|
affected.add(nodeId);
|
|
}
|
|
}
|
|
|
|
for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) {
|
|
affected.add(nodeId);
|
|
for (const affectedNodeId of findAffectedNodes(graph, nodeId)) {
|
|
affected.add(affectedNodeId);
|
|
}
|
|
}
|
|
|
|
return [...affected];
|
|
}
|
|
|
|
function buildChangesApplied(proposal, affectedNodeIds) {
|
|
return {
|
|
addedNodeCount: proposal.addedNodes.length,
|
|
addedUnknownCount: proposal.addedNodes.filter(
|
|
(node) => node.kind === "unknown",
|
|
).length,
|
|
updatedNodeCount: proposal.updatedNodes.length,
|
|
addedEdgeCount: proposal.addedEdges.length,
|
|
removedEdgeCount: proposal.removedEdgeIds.length,
|
|
resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length,
|
|
affectedNodeCount: affectedNodeIds.length,
|
|
};
|
|
}
|
|
|
|
function appendUniqueValue(values = [], nextValue) {
|
|
return nextValue && !values.includes(nextValue)
|
|
? [...values, nextValue]
|
|
: values;
|
|
}
|
|
|
|
function getNodeConfidenceAssessment(node) {
|
|
return (
|
|
node?.confidenceAssessment || {
|
|
evidenceConfidence: node?.confidence ?? "medium",
|
|
completenessStatus:
|
|
node?.status === "resolved"
|
|
? "complete"
|
|
: node?.status === "provisional"
|
|
? "partial"
|
|
: "empty",
|
|
conclusionConfidence:
|
|
node?.status === "resolved"
|
|
? (node?.confidence ?? "high")
|
|
: node?.status === "provisional"
|
|
? (node?.confidence ?? "medium")
|
|
: "low",
|
|
}
|
|
);
|
|
}
|
|
|
|
function confidenceFromAssessment(assessment) {
|
|
return assessment?.conclusionConfidence ?? "medium";
|
|
}
|
|
|
|
function unique(values = []) {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
function branchEvidenceKeys(node) {
|
|
return unique([...(node?.evidenceIds || []), node?.value]);
|
|
}
|
|
|
|
function sharedMeaningfulTokens(aText, bText) {
|
|
const stop = new Set([
|
|
"the",
|
|
"and",
|
|
"for",
|
|
"that",
|
|
"this",
|
|
"with",
|
|
"from",
|
|
"because",
|
|
"need",
|
|
"unknown",
|
|
"possible",
|
|
]);
|
|
const a = splitSemanticTokens(aText).filter((token) => !stop.has(token));
|
|
const b = splitSemanticTokens(bText).filter((token) => !stop.has(token));
|
|
return [...new Set(a.filter((token) => b.includes(token)))];
|
|
}
|
|
|
|
function branchConflictSignature(node) {
|
|
return normaliseText(
|
|
`${node?.label || ""} ${node?.description || ""} ${node?.value || ""}`,
|
|
);
|
|
}
|
|
|
|
function branchesConflict(aNode, bNode) {
|
|
const aText = branchConflictSignature(aNode);
|
|
const bText = branchConflictSignature(bNode);
|
|
const oppositePolarity =
|
|
(aText.includes("correctly") && bText.includes("incorrectly")) ||
|
|
(aText.includes("incorrectly") && bText.includes("correctly")) ||
|
|
aNode?.status === "contradicted" ||
|
|
bNode?.status === "contradicted";
|
|
|
|
if (!oppositePolarity) return false;
|
|
|
|
return sharedMeaningfulTokens(aText, bText).length >= 2;
|
|
}
|
|
|
|
export function evaluateBranchInteractions({ parentNode, graph }) {
|
|
const directBranches = findDirectChildUnknowns(graph, parentNode.id).filter(
|
|
(node) => ["resolved", "provisional", "contradicted"].includes(node.status),
|
|
);
|
|
const duplicateEvidenceGroups = [];
|
|
const conflictingBranches = [];
|
|
const corroboratingBranches = [];
|
|
const duplicateBranchIds = new Set();
|
|
const conflictingBranchIds = new Set();
|
|
|
|
const evidenceGroups = new Map();
|
|
for (const branch of directBranches) {
|
|
for (const evidenceKey of branchEvidenceKeys(branch)) {
|
|
const ids = evidenceGroups.get(evidenceKey) || [];
|
|
ids.push(branch.id);
|
|
evidenceGroups.set(evidenceKey, ids);
|
|
}
|
|
}
|
|
|
|
for (const [evidenceKey, branchIds] of evidenceGroups.entries()) {
|
|
if (branchIds.length > 1) {
|
|
duplicateEvidenceGroups.push({
|
|
evidenceKey,
|
|
branchIds: unique(branchIds),
|
|
});
|
|
for (const id of branchIds) duplicateBranchIds.add(id);
|
|
}
|
|
}
|
|
|
|
for (let index = 0; index < directBranches.length; index += 1) {
|
|
for (let inner = index + 1; inner < directBranches.length; inner += 1) {
|
|
const aNode = directBranches[index];
|
|
const bNode = directBranches[inner];
|
|
if (branchesConflict(aNode, bNode)) {
|
|
conflictingBranches.push([aNode.id, bNode.id]);
|
|
conflictingBranchIds.add(aNode.id);
|
|
conflictingBranchIds.add(bNode.id);
|
|
continue;
|
|
}
|
|
|
|
const aEvidence = branchEvidenceKeys(aNode);
|
|
const bEvidence = branchEvidenceKeys(bNode);
|
|
const sharesEvidence = aEvidence.some((key) => bEvidence.includes(key));
|
|
if (
|
|
!sharesEvidence &&
|
|
aNode.status === "resolved" &&
|
|
bNode.status === "resolved"
|
|
) {
|
|
corroboratingBranches.push([aNode.id, bNode.id]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const interactionBranchIds = new Set([
|
|
...duplicateBranchIds,
|
|
...conflictingBranchIds,
|
|
...corroboratingBranches.flat(),
|
|
]);
|
|
const independentBranches = directBranches
|
|
.map((branch) => branch.id)
|
|
.filter((id) => !interactionBranchIds.has(id));
|
|
|
|
return {
|
|
corroboratingBranches,
|
|
conflictingBranches,
|
|
duplicateEvidenceGroups,
|
|
independentBranches,
|
|
interactionSummary: {
|
|
corroboratingBranchCount: corroboratingBranches.length,
|
|
conflictingBranchCount: conflictingBranches.length,
|
|
duplicateEvidenceCount: duplicateEvidenceGroups.length,
|
|
independentBranchCount: independentBranches.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
function upsertProposalNodeUpdate(proposalSnapshot, update) {
|
|
const existing = proposalSnapshot.updatedNodes.find(
|
|
(candidate) => candidate.nodeId === update.nodeId,
|
|
);
|
|
|
|
if (existing) {
|
|
if (update.newStatus != null) existing.newStatus = update.newStatus;
|
|
if (update.newValue !== undefined) existing.newValue = update.newValue;
|
|
if (existing.previousStatus == null) {
|
|
existing.previousStatus = update.previousStatus ?? null;
|
|
}
|
|
if (existing.previousValue === undefined) {
|
|
existing.previousValue = update.previousValue ?? null;
|
|
}
|
|
existing.reason = update.reason;
|
|
return existing;
|
|
}
|
|
|
|
proposalSnapshot.updatedNodes.push(update);
|
|
return update;
|
|
}
|
|
|
|
function ensureResolvedUnknownId(proposalSnapshot, nodeId) {
|
|
if (!proposalSnapshot.resolvedUnknownNodeIds.includes(nodeId)) {
|
|
proposalSnapshot.resolvedUnknownNodeIds.push(nodeId);
|
|
}
|
|
}
|
|
|
|
function buildPropagationEvidenceId(nodeId) {
|
|
return `answer:${nodeId}`;
|
|
}
|
|
|
|
function findDirectChildUnknowns(graph, parentNodeId) {
|
|
const parentNode = (graph.nodes || []).find(
|
|
(node) => node.id === parentNodeId,
|
|
);
|
|
const childIds = new Set(parentNode?.childIds || []);
|
|
|
|
for (const edge of graph.edges || []) {
|
|
if (edge.toNodeId === parentNodeId && edge.relationship === "depends_on") {
|
|
childIds.add(edge.fromNodeId);
|
|
}
|
|
}
|
|
|
|
return (graph.nodes || []).filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
(node.parentId === parentNodeId || childIds.has(node.id)),
|
|
);
|
|
}
|
|
|
|
function hasExistingDecompositionChildren(graph, parentNodeId) {
|
|
return findDirectChildUnknowns(graph, parentNodeId).length > 0;
|
|
}
|
|
|
|
function buildAncestorChain(graph, node) {
|
|
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
|
|
const chain = [];
|
|
const queue = [node?.parentId ?? null].filter(Boolean);
|
|
const seen = new Set();
|
|
|
|
while (queue.length > 0) {
|
|
const currentParentId = queue.shift();
|
|
if (!currentParentId || seen.has(currentParentId)) continue;
|
|
seen.add(currentParentId);
|
|
|
|
const parentNode = nodesById.get(currentParentId);
|
|
if (!parentNode) continue;
|
|
chain.push(parentNode);
|
|
|
|
if (parentNode.parentId) {
|
|
queue.push(parentNode.parentId);
|
|
}
|
|
|
|
for (const candidate of graph.nodes || []) {
|
|
if (
|
|
candidate.id !== parentNode.id &&
|
|
(candidate.childIds || []).includes(parentNode.id)
|
|
) {
|
|
queue.push(candidate.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return chain;
|
|
}
|
|
|
|
function syncParentChildReferences(graph) {
|
|
const nodesById = new Map((graph.nodes || []).map((node) => [node.id, node]));
|
|
|
|
for (const node of graph.nodes || []) {
|
|
if (!node.parentId) continue;
|
|
const parentNode = nodesById.get(node.parentId);
|
|
if (!parentNode) continue;
|
|
|
|
parentNode.childIds = appendUniqueValue(parentNode.childIds || [], node.id);
|
|
parentNode.dependsOn = appendUniqueValue(
|
|
parentNode.dependsOn || [],
|
|
node.id,
|
|
);
|
|
}
|
|
|
|
return graph;
|
|
}
|
|
|
|
function computeParentProgressState(graph, parentNode) {
|
|
const childUnknowns = findDirectChildUnknowns(graph, parentNode.id);
|
|
const resolvedChildren = childUnknowns.filter(
|
|
(child) => child.status === "resolved",
|
|
);
|
|
const progressedChildren = childUnknowns.filter((child) =>
|
|
["resolved", "provisional"].includes(child.status),
|
|
);
|
|
const contradictoryChildren = childUnknowns.filter(
|
|
(child) => child.status === "contradicted",
|
|
);
|
|
const totalChildren = childUnknowns.length;
|
|
const resolvedCount = resolvedChildren.length;
|
|
const unresolvedCount = childUnknowns.filter(
|
|
(child) => child.status !== "resolved",
|
|
).length;
|
|
const beforeAssessment = getNodeConfidenceAssessment(parentNode);
|
|
const interactions = evaluateBranchInteractions({ parentNode, graph });
|
|
const corroborationCount =
|
|
interactions.interactionSummary.corroboratingBranchCount;
|
|
const duplicateEvidenceCount =
|
|
interactions.interactionSummary.duplicateEvidenceCount;
|
|
const conflictingBranchCount =
|
|
interactions.interactionSummary.conflictingBranchCount;
|
|
|
|
if (totalChildren === 0) {
|
|
const nextAssessment = {
|
|
evidenceConfidence: beforeAssessment.evidenceConfidence,
|
|
completenessStatus: beforeAssessment.completenessStatus,
|
|
conclusionConfidence: beforeAssessment.conclusionConfidence,
|
|
};
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: parentNode.status,
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: parentNode.status === "resolved",
|
|
confidenceCapReason: "no_child_structure",
|
|
reason: "Parent has no child unknowns to aggregate.",
|
|
};
|
|
}
|
|
|
|
let nextAssessment;
|
|
let confidenceCapReason;
|
|
|
|
if (contradictoryChildren.length > 0) {
|
|
nextAssessment = {
|
|
evidenceConfidence: resolvedCount > 0 ? "medium" : "low",
|
|
completenessStatus: resolvedCount === 0 ? "empty" : "partial",
|
|
conclusionConfidence: "low",
|
|
};
|
|
confidenceCapReason = "contradictory_direct_children";
|
|
} else if (resolvedCount === 0) {
|
|
nextAssessment = {
|
|
evidenceConfidence: "low",
|
|
completenessStatus: "empty",
|
|
conclusionConfidence: "low",
|
|
};
|
|
confidenceCapReason = "no_resolved_direct_children";
|
|
} else if (resolvedCount < totalChildren) {
|
|
nextAssessment = {
|
|
evidenceConfidence: corroborationCount > 0 ? "high" : "medium",
|
|
completenessStatus: "partial",
|
|
conclusionConfidence: "medium",
|
|
};
|
|
confidenceCapReason =
|
|
conflictingBranchCount > 0
|
|
? "conflicting_branches_cap_conclusion"
|
|
: duplicateEvidenceCount > 0
|
|
? "duplicate_evidence_no_extra_confidence"
|
|
: corroborationCount > 0
|
|
? "independent_corroboration_with_incomplete_parent"
|
|
: "unresolved_direct_children_cap_conclusion";
|
|
} else {
|
|
nextAssessment = {
|
|
evidenceConfidence: "high",
|
|
completenessStatus: "complete",
|
|
conclusionConfidence: conflictingBranchCount > 0 ? "low" : "high",
|
|
};
|
|
confidenceCapReason =
|
|
conflictingBranchCount > 0
|
|
? "conflicting_branches_cap_conclusion"
|
|
: duplicateEvidenceCount > 0
|
|
? "duplicate_evidence_no_extra_confidence"
|
|
: corroborationCount > 0
|
|
? "independent_corroboration_supported_conclusion"
|
|
: null;
|
|
}
|
|
|
|
if (resolvedChildren.length === totalChildren) {
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: "resolved",
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: true,
|
|
resolvedDirectChildren: resolvedCount,
|
|
unresolvedDirectChildren: unresolvedCount,
|
|
contradictoryDirectChildren: contradictoryChildren.length,
|
|
branchInteractions: interactions,
|
|
confidenceCapReason,
|
|
reason:
|
|
"All direct child unknowns are resolved, so the parent can now resolve deterministically.",
|
|
};
|
|
}
|
|
|
|
if (progressedChildren.length > 0) {
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: "provisional",
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: false,
|
|
resolvedDirectChildren: resolvedCount,
|
|
unresolvedDirectChildren: unresolvedCount,
|
|
contradictoryDirectChildren: contradictoryChildren.length,
|
|
branchInteractions: interactions,
|
|
confidenceCapReason,
|
|
reason:
|
|
"At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
totalChildren,
|
|
resolvedChildren,
|
|
progressedChildren,
|
|
contradictoryChildren,
|
|
nextStatus: parentNode.status,
|
|
nextConfidence: confidenceFromAssessment(nextAssessment),
|
|
nextConfidenceAssessment: nextAssessment,
|
|
parentResolved: parentNode.status === "resolved",
|
|
resolvedDirectChildren: resolvedCount,
|
|
unresolvedDirectChildren: unresolvedCount,
|
|
contradictoryDirectChildren: contradictoryChildren.length,
|
|
branchInteractions: interactions,
|
|
confidenceCapReason,
|
|
reason: "No direct child progress exists yet for the parent.",
|
|
};
|
|
}
|
|
|
|
export function propagateResolvedChildEvidence({
|
|
updatedSituationGraph,
|
|
proposalSnapshot,
|
|
}) {
|
|
const resolvedChildNodes = (updatedSituationGraph.nodes || []).filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
node.parentId &&
|
|
proposalSnapshot.resolvedUnknownNodeIds.includes(node.id),
|
|
);
|
|
|
|
if (resolvedChildNodes.length === 0) {
|
|
return {
|
|
graph: updatedSituationGraph,
|
|
proposalSnapshot,
|
|
propagationPerformed: false,
|
|
resolvedChildNodeId: null,
|
|
parentNodeId: null,
|
|
parentStatusBefore: null,
|
|
parentStatusAfter: null,
|
|
parentConfidenceBefore: null,
|
|
parentConfidenceAfter: null,
|
|
evidenceConfidenceBefore: null,
|
|
evidenceConfidenceAfter: null,
|
|
completenessBefore: null,
|
|
completenessAfter: null,
|
|
conclusionConfidenceBefore: null,
|
|
conclusionConfidenceAfter: null,
|
|
resolvedDirectChildren: 0,
|
|
unresolvedDirectChildren: 0,
|
|
contradictoryDirectChildren: 0,
|
|
confidenceCapReason: null,
|
|
ancestorPropagationStoppedReason: "no_resolved_child_propagation_needed",
|
|
affectedAncestorIds: [],
|
|
nextSelectedSibling: null,
|
|
parentResolved: false,
|
|
reason: "No resolved decomposition child required upward propagation.",
|
|
};
|
|
}
|
|
|
|
const graph = cloneJsonSafe(updatedSituationGraph);
|
|
syncParentChildReferences(graph);
|
|
const propagationEvents = [];
|
|
const affectedAncestorIds = new Set();
|
|
let ancestorPropagationStoppedReason = "no_ancestor_state_changed";
|
|
|
|
for (const resolvedChildNode of resolvedChildNodes) {
|
|
const liveChildNode = graph.nodes.find(
|
|
(node) => node.id === resolvedChildNode.id,
|
|
);
|
|
if (!liveChildNode) continue;
|
|
|
|
liveChildNode.evidenceIds = appendUniqueValue(
|
|
liveChildNode.evidenceIds || [],
|
|
buildPropagationEvidenceId(liveChildNode.id),
|
|
);
|
|
|
|
const ancestorChain = buildAncestorChain(graph, liveChildNode);
|
|
for (const ancestorNode of ancestorChain) {
|
|
const beforeStatus = ancestorNode.status;
|
|
const beforeConfidence = ancestorNode.confidence;
|
|
const beforeAssessment = getNodeConfidenceAssessment(ancestorNode);
|
|
const progressState = computeParentProgressState(graph, ancestorNode);
|
|
|
|
ancestorNode.status = progressState.nextStatus;
|
|
ancestorNode.confidence = progressState.nextConfidence;
|
|
ancestorNode.confidenceAssessment =
|
|
progressState.nextConfidenceAssessment;
|
|
|
|
if (
|
|
beforeStatus === progressState.nextStatus &&
|
|
beforeConfidence === progressState.nextConfidence &&
|
|
JSON.stringify(beforeAssessment) ===
|
|
JSON.stringify(progressState.nextConfidenceAssessment)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
ancestorPropagationStoppedReason = "ancestor_state_changed";
|
|
|
|
if (progressState.parentResolved) {
|
|
ensureResolvedUnknownId(proposalSnapshot, ancestorNode.id);
|
|
}
|
|
|
|
upsertProposalNodeUpdate(proposalSnapshot, {
|
|
nodeId: ancestorNode.id,
|
|
previousStatus: beforeStatus,
|
|
newStatus: progressState.nextStatus,
|
|
previousValue: ancestorNode.value ?? null,
|
|
newValue: ancestorNode.value ?? null,
|
|
reason: progressState.reason,
|
|
});
|
|
|
|
affectedAncestorIds.add(ancestorNode.id);
|
|
propagationEvents.push({
|
|
resolvedChildNodeId: liveChildNode.id,
|
|
parentNodeId: ancestorNode.id,
|
|
parentStatusBefore: beforeStatus,
|
|
parentStatusAfter: progressState.nextStatus,
|
|
parentConfidenceBefore: beforeConfidence,
|
|
parentConfidenceAfter: progressState.nextConfidence,
|
|
evidenceConfidenceBefore: beforeAssessment.evidenceConfidence,
|
|
evidenceConfidenceAfter:
|
|
progressState.nextConfidenceAssessment.evidenceConfidence,
|
|
completenessBefore: beforeAssessment.completenessStatus,
|
|
completenessAfter:
|
|
progressState.nextConfidenceAssessment.completenessStatus,
|
|
conclusionConfidenceBefore: beforeAssessment.conclusionConfidence,
|
|
conclusionConfidenceAfter:
|
|
progressState.nextConfidenceAssessment.conclusionConfidence,
|
|
resolvedDirectChildren: progressState.resolvedDirectChildren,
|
|
unresolvedDirectChildren: progressState.unresolvedDirectChildren,
|
|
contradictoryDirectChildren: progressState.contradictoryDirectChildren,
|
|
corroboratingBranchCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.corroboratingBranchCount,
|
|
conflictingBranchCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.conflictingBranchCount,
|
|
duplicateEvidenceCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.duplicateEvidenceCount,
|
|
independentBranchCount:
|
|
progressState.branchInteractions.interactionSummary
|
|
.independentBranchCount,
|
|
interactionSummary: progressState.branchInteractions.interactionSummary,
|
|
confidenceCapReason: progressState.confidenceCapReason,
|
|
parentResolved: progressState.parentResolved,
|
|
reason: progressState.reason,
|
|
});
|
|
}
|
|
}
|
|
|
|
graph.resolvedNodeIds = [
|
|
...new Set([
|
|
...graph.resolvedNodeIds,
|
|
...proposalSnapshot.resolvedUnknownNodeIds,
|
|
]),
|
|
];
|
|
|
|
const siblingSelection = selectActiveUnknownCandidate(
|
|
graph,
|
|
graph.resolvedNodeIds,
|
|
);
|
|
const firstEvent = propagationEvents[0] ?? null;
|
|
|
|
return {
|
|
graph,
|
|
proposalSnapshot,
|
|
propagationPerformed: propagationEvents.length > 0,
|
|
resolvedChildNodeId: firstEvent?.resolvedChildNodeId ?? null,
|
|
parentNodeId: firstEvent?.parentNodeId ?? null,
|
|
parentStatusBefore: firstEvent?.parentStatusBefore ?? null,
|
|
parentStatusAfter: firstEvent?.parentStatusAfter ?? null,
|
|
parentConfidenceBefore: firstEvent?.parentConfidenceBefore ?? null,
|
|
parentConfidenceAfter: firstEvent?.parentConfidenceAfter ?? null,
|
|
evidenceConfidenceBefore: firstEvent?.evidenceConfidenceBefore ?? null,
|
|
evidenceConfidenceAfter: firstEvent?.evidenceConfidenceAfter ?? null,
|
|
completenessBefore: firstEvent?.completenessBefore ?? null,
|
|
completenessAfter: firstEvent?.completenessAfter ?? null,
|
|
conclusionConfidenceBefore: firstEvent?.conclusionConfidenceBefore ?? null,
|
|
conclusionConfidenceAfter: firstEvent?.conclusionConfidenceAfter ?? null,
|
|
resolvedDirectChildren: firstEvent?.resolvedDirectChildren ?? 0,
|
|
unresolvedDirectChildren: firstEvent?.unresolvedDirectChildren ?? 0,
|
|
contradictoryDirectChildren: firstEvent?.contradictoryDirectChildren ?? 0,
|
|
corroboratingBranchCount: firstEvent?.corroboratingBranchCount ?? 0,
|
|
conflictingBranchCount: firstEvent?.conflictingBranchCount ?? 0,
|
|
duplicateEvidenceCount: firstEvent?.duplicateEvidenceCount ?? 0,
|
|
independentBranchCount: firstEvent?.independentBranchCount ?? 0,
|
|
interactionSummary: firstEvent?.interactionSummary ?? null,
|
|
confidenceCapReason: firstEvent?.confidenceCapReason ?? null,
|
|
ancestorPropagationStoppedReason,
|
|
affectedAncestorIds: [...affectedAncestorIds],
|
|
nextSelectedSibling:
|
|
siblingSelection?.status === "selected" ? siblingSelection.nodeId : null,
|
|
parentResolved: firstEvent?.parentResolved ?? false,
|
|
reason:
|
|
firstEvent?.reason ??
|
|
"Resolved child evidence propagated upward through the decomposition chain.",
|
|
};
|
|
}
|
|
|
|
function buildEmergentReasoningUnknownLabel(graph) {
|
|
const central = String(graph?.centralStatement || "these observations")
|
|
.trim()
|
|
.replace(/[.?!:;]+$/g, "");
|
|
return `Explanation for why ${central}`;
|
|
}
|
|
|
|
function findEquivalentEmergentUnknown(graph, label, description) {
|
|
const targetId = makeNodeId(label);
|
|
const targetTexts = [normaliseText(label), normaliseText(description)].filter(
|
|
Boolean,
|
|
);
|
|
|
|
return (graph.nodes || []).find((node) => {
|
|
if (
|
|
node.kind !== "unknown" ||
|
|
(graph.resolvedNodeIds || []).includes(node.id)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (node.id === targetId) {
|
|
return true;
|
|
}
|
|
|
|
const nodeTexts = [
|
|
normaliseText(node.label),
|
|
normaliseText(node.description),
|
|
].filter(Boolean);
|
|
|
|
return targetTexts.some((text) => nodeTexts.includes(text));
|
|
});
|
|
}
|
|
|
|
function buildEmergentReasoningUnknown(graph, relationshipAssessment) {
|
|
if (!relationshipAssessment?.relationshipAssessed) {
|
|
return null;
|
|
}
|
|
|
|
if (!relationshipAssessment.questionRequired) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
![
|
|
"potentially_related",
|
|
"insufficient_information",
|
|
"contradictory",
|
|
].includes(relationshipAssessment.relationshipStatus)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const label = buildEmergentReasoningUnknownLabel(graph);
|
|
const description =
|
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.";
|
|
const existingNode = findEquivalentEmergentUnknown(graph, label, description);
|
|
if (existingNode) {
|
|
return {
|
|
created: false,
|
|
node: existingNode,
|
|
edges: [],
|
|
reason:
|
|
"Reused an existing unresolved reasoning unknown for the next investigation stage.",
|
|
};
|
|
}
|
|
|
|
const observationNodes = (graph.nodes || []).filter(
|
|
(node) => node.kind === "observation" && node.status === "supported",
|
|
);
|
|
const relationshipNode = (graph.nodes || []).find(
|
|
(node) => node.kind === "relationship" && node.status === "supported",
|
|
);
|
|
const nodeId = makeNodeId(label);
|
|
const relatedNodeIds = relationshipNode
|
|
? [relationshipNode.id]
|
|
: observationNodes.slice(0, 2).map((node) => node.id);
|
|
|
|
if (relatedNodeIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const node = {
|
|
id: nodeId,
|
|
label,
|
|
description,
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: relatedNodeIds,
|
|
affects: [],
|
|
parentId: relationshipNode?.id ?? null,
|
|
childIds: [],
|
|
};
|
|
|
|
const edges = relatedNodeIds.map((relatedNodeId) => ({
|
|
id: `e-${relatedNodeId.slice(0, 6)}-${nodeId.slice(0, 6)}`,
|
|
fromNodeId: relatedNodeId,
|
|
toNodeId: nodeId,
|
|
relationship:
|
|
relationshipNode?.id === relatedNodeId ? "depends_on" : "other",
|
|
confidence: "medium",
|
|
description:
|
|
"This unresolved explanation arises from the now-assessed relationship between the observations.",
|
|
}));
|
|
|
|
return {
|
|
created: true,
|
|
node,
|
|
edges,
|
|
reason:
|
|
"Created a new unresolved reasoning unknown so the next justified question is backed by the graph.",
|
|
};
|
|
}
|
|
|
|
function stripTrailingPunctuation(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.replace(/[.?!:;]+$/g, "")
|
|
.trim();
|
|
}
|
|
|
|
function collectSupportedObservations(graph) {
|
|
return (graph.nodes || []).filter(
|
|
(node) => node.kind === "observation" && node.status === "supported",
|
|
);
|
|
}
|
|
|
|
function detectObservationConcept(text) {
|
|
const normalised = normaliseText(text);
|
|
const concepts = [
|
|
["revenue", /\brevenue\b/],
|
|
["cash", /\bcash\b/],
|
|
["customer satisfaction", /\bsatisfaction\b/],
|
|
["complaints", /\bcomplaints?\b/],
|
|
["delivery time", /\bdelivery time\b|\bdelivery\b/],
|
|
["cancellations", /\bcancellations?\b/],
|
|
["traffic", /\btraffic\b/],
|
|
["sales", /\bsales\b/],
|
|
["production", /\bproduction\b|\boutput\b/],
|
|
["defects", /\bdefects?\b/],
|
|
["quality", /\bquality\b/],
|
|
];
|
|
|
|
for (const [label, pattern] of concepts) {
|
|
if (pattern.test(normalised)) return label;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildDecompositionContext(graph) {
|
|
const observations = collectSupportedObservations(graph);
|
|
const firstObservation = observations[0] ?? null;
|
|
const secondObservation = observations[1] ?? null;
|
|
const firstConcept = detectObservationConcept(
|
|
`${firstObservation?.label || ""} ${firstObservation?.description || ""}`,
|
|
);
|
|
const secondConcept = detectObservationConcept(
|
|
`${secondObservation?.label || ""} ${secondObservation?.description || ""}`,
|
|
);
|
|
|
|
return {
|
|
centralStatement: stripTrailingPunctuation(graph.centralStatement),
|
|
firstConcept: firstConcept || "the first signal",
|
|
secondConcept: secondConcept || "the second signal",
|
|
firstObservationLabel: stripTrailingPunctuation(
|
|
firstObservation?.label || "",
|
|
),
|
|
secondObservationLabel: stripTrailingPunctuation(
|
|
secondObservation?.label || "",
|
|
),
|
|
};
|
|
}
|
|
|
|
export const MAX_DECOMPOSITION_DEPTH = 2;
|
|
|
|
function splitSemanticTokens(value) {
|
|
return normaliseText(value)
|
|
.split(" ")
|
|
.filter((token) => token.length > 2);
|
|
}
|
|
|
|
function buildSemanticSignature(node) {
|
|
return normaliseText(`${node?.label || ""} ${node?.description || ""}`);
|
|
}
|
|
|
|
function calculateTokenOverlapRatio(aTokens, bTokens) {
|
|
const a = new Set(aTokens);
|
|
const b = new Set(bTokens);
|
|
const intersection = [...a].filter((token) => b.has(token)).length;
|
|
const largest = Math.max(a.size, b.size, 1);
|
|
return intersection / largest;
|
|
}
|
|
|
|
function detectCompoundSignals(text) {
|
|
const signals = [];
|
|
|
|
if (/\b(and|or)\b/.test(text)) {
|
|
if (
|
|
/\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b[^.]{0,30}\b(and|or)\b[^.]{0,30}\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b/.test(
|
|
text,
|
|
)
|
|
) {
|
|
signals.push("conjoined_distinct_concepts");
|
|
}
|
|
}
|
|
|
|
if (/\b[a-z]+\s*\/\s*[a-z]+\b/.test(text)) {
|
|
signals.push("slash_separated_categories");
|
|
}
|
|
|
|
if (/,[^,]{0,20},/.test(text) || /,\s*[^,]+\s+or\s+[^,]+/.test(text)) {
|
|
signals.push("comma_separated_category_list");
|
|
}
|
|
|
|
if (/\btiming or measurement basis\b/.test(text)) {
|
|
signals.push("timing_or_measurement_basis");
|
|
}
|
|
|
|
return [...new Set(signals)];
|
|
}
|
|
|
|
function isDirectlyAnswerableChildText(text) {
|
|
return !/\b(explanation for why|possible causes|possible reasons|what changed|difference between|divergence|moved differently|factors behind|factors affecting)\b/.test(
|
|
text,
|
|
);
|
|
}
|
|
|
|
function buildRejectedChildRecord(childNode, reasons, compoundSignals) {
|
|
return {
|
|
nodeId: childNode.id,
|
|
label: childNode.label,
|
|
reasons,
|
|
compoundSignals,
|
|
};
|
|
}
|
|
|
|
function mergeUniqueRecords(existing = [], incoming = [], key = "nodeId") {
|
|
const merged = new Map((existing || []).map((item) => [item[key], item]));
|
|
for (const item of incoming || []) {
|
|
merged.set(item[key], item);
|
|
}
|
|
return [...merged.values()];
|
|
}
|
|
|
|
function cloneNode(node) {
|
|
return JSON.parse(JSON.stringify(node));
|
|
}
|
|
|
|
export function assessChildUnknownQuality({
|
|
parentNode,
|
|
childNode,
|
|
siblingNodes,
|
|
graph,
|
|
}) {
|
|
const parentSignature = buildSemanticSignature(parentNode);
|
|
const childSignature = buildSemanticSignature(childNode);
|
|
const parentTokens = splitSemanticTokens(parentSignature);
|
|
const childTokens = splitSemanticTokens(childSignature);
|
|
const compoundSignals = detectCompoundSignals(childSignature);
|
|
const duplicateSiblingIds = (siblingNodes || [])
|
|
.filter((sibling) => sibling.id !== childNode.id)
|
|
.filter((sibling) => buildSemanticSignature(sibling) === childSignature)
|
|
.map((sibling) => sibling.id);
|
|
const reasons = [];
|
|
|
|
const narrowerThanParent =
|
|
childSignature !== parentSignature &&
|
|
(childTokens.length < parentTokens.length ||
|
|
calculateTokenOverlapRatio(parentTokens, childTokens) < 0.8);
|
|
|
|
const directlyAnswerable =
|
|
isDirectlyAnswerableChildText(childSignature) &&
|
|
compoundSignals.length === 0;
|
|
|
|
const independent =
|
|
duplicateSiblingIds.length === 0 && compoundSignals.length === 0;
|
|
const atomic = narrowerThanParent && directlyAnswerable && independent;
|
|
|
|
if (!narrowerThanParent) {
|
|
reasons.push("not_narrower_than_parent");
|
|
}
|
|
if (!directlyAnswerable) {
|
|
reasons.push("not_directly_answerable");
|
|
}
|
|
if (compoundSignals.length > 0) {
|
|
reasons.push("compound_child");
|
|
}
|
|
if (duplicateSiblingIds.length > 0) {
|
|
reasons.push("duplicate_sibling");
|
|
}
|
|
if ((graph?.resolvedNodeIds || []).includes(childNode.id)) {
|
|
reasons.push("already_resolved");
|
|
}
|
|
|
|
return {
|
|
valid: reasons.length === 0,
|
|
atomic,
|
|
directlyAnswerable,
|
|
independent,
|
|
narrowerThanParent,
|
|
compoundSignals,
|
|
duplicateSiblingIds,
|
|
reasons,
|
|
};
|
|
}
|
|
|
|
function buildDecompositionChildId(parentNodeId, label) {
|
|
return makeNodeId(`${parentNodeId}:${label}`);
|
|
}
|
|
|
|
function findEquivalentDecompositionChild(
|
|
graph,
|
|
parentNodeId,
|
|
label,
|
|
description,
|
|
) {
|
|
const targetId = buildDecompositionChildId(parentNodeId, label);
|
|
const targetTexts = [normaliseText(label), normaliseText(description)].filter(
|
|
Boolean,
|
|
);
|
|
|
|
return (graph.nodes || []).find((node) => {
|
|
if (
|
|
node.kind !== "unknown" ||
|
|
node.parentId !== parentNodeId ||
|
|
(graph.resolvedNodeIds || []).includes(node.id)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (node.id === targetId) {
|
|
return true;
|
|
}
|
|
|
|
const nodeTexts = [
|
|
normaliseText(node.label),
|
|
normaliseText(node.description),
|
|
].filter(Boolean);
|
|
|
|
return targetTexts.some((text) => nodeTexts.includes(text));
|
|
});
|
|
}
|
|
|
|
function describeObservationFocus(context, which) {
|
|
const concept =
|
|
which === "first" ? context.firstConcept : context.secondConcept;
|
|
const label =
|
|
which === "first"
|
|
? context.firstObservationLabel
|
|
: context.secondObservationLabel;
|
|
|
|
if (concept && !concept.startsWith("the ")) return concept;
|
|
if (label) return label.toLowerCase();
|
|
return which === "first" ? "the first observation" : "the second observation";
|
|
}
|
|
|
|
function parentSupportsComparisonDecomposition(parentText) {
|
|
return /\b(compare|comparison|comparable|comparability|different timing|timing|measured|measurement|basis|scale|same period|timing or measurement basis|two observations)\b/.test(
|
|
parentText,
|
|
);
|
|
}
|
|
|
|
function buildDecompositionTemplates(parentNode, graph, depth = 0) {
|
|
const parentText = normaliseText(
|
|
`${parentNode?.label || ""} ${parentNode?.description || ""}`,
|
|
);
|
|
const context = buildDecompositionContext(graph);
|
|
const firstFocus = describeObservationFocus(context, "first");
|
|
const secondFocus = describeObservationFocus(context, "second");
|
|
|
|
if (
|
|
/\b(genuine problem|commercially justified|commercial justification|people would value|pay for it|justified confidence|decision support methods|willingness to pay|seek help)\b/.test(
|
|
parentText,
|
|
)
|
|
) {
|
|
return [
|
|
{
|
|
label: "Who experiences this problem",
|
|
description:
|
|
"Need to know who experiences this problem, because that must be clear before deciding whether it is commercially justified.",
|
|
dependsOnLabels: [],
|
|
},
|
|
{
|
|
label: "Whether other people experience this problem",
|
|
description:
|
|
"Need to know whether other people experience this problem, because that must be established before deciding whether the problem is broadly important.",
|
|
dependsOnLabels: ["Who experiences this problem"],
|
|
},
|
|
{
|
|
label: "How often this problem happens",
|
|
description:
|
|
"Need to know how often this problem happens, because that helps judge whether it is a real recurring problem.",
|
|
dependsOnLabels: [
|
|
"Who experiences this problem",
|
|
"Whether other people experience this problem",
|
|
],
|
|
},
|
|
{
|
|
label: "What happens when this problem is not resolved",
|
|
description:
|
|
"Need to know what happens when this problem is not resolved, because that is needed before judging whether the problem matters.",
|
|
dependsOnLabels: ["Who experiences this problem"],
|
|
},
|
|
{
|
|
label: "How people deal with this problem today",
|
|
description:
|
|
"Need to know how people deal with this problem today, because that is needed before comparing alternatives or value.",
|
|
dependsOnLabels: [
|
|
"Who experiences this problem",
|
|
"Whether other people experience this problem",
|
|
"What happens when this problem is not resolved",
|
|
"How often this problem happens",
|
|
],
|
|
},
|
|
depth === 0
|
|
? {
|
|
label: "Whether people actively look for help with this problem",
|
|
description:
|
|
"Need to know whether people actively look for help with this problem, because that is needed before judging demand or willingness to pay.",
|
|
dependsOnLabels: [
|
|
"Who experiences this problem",
|
|
"Whether other people experience this problem",
|
|
"What happens when this problem is not resolved",
|
|
"How often this problem happens",
|
|
"How people deal with this problem today",
|
|
],
|
|
}
|
|
: {
|
|
label: "Whether people would pay to solve this problem",
|
|
description:
|
|
"Need to know whether people would pay to solve this problem, because that can only be judged after the problem itself is established.",
|
|
dependsOnLabels: [
|
|
"Who experiences this problem",
|
|
"Whether other people experience this problem",
|
|
"What happens when this problem is not resolved",
|
|
"How often this problem happens",
|
|
"How people deal with this problem today",
|
|
"Whether people actively look for help with this problem",
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (parentSupportsComparisonDecomposition(parentText)) {
|
|
return [
|
|
{
|
|
label: "Whether the two observations reflect different timing",
|
|
description: `Need to know whether the two observations reflect different timing, because that would help resolve ${context.centralStatement}.`,
|
|
},
|
|
{
|
|
label: "How the two observations were measured",
|
|
description: `Need evidence about the measure used for each observation, because that would help resolve ${context.centralStatement}.`,
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
|
|
const templates = buildDecompositionTemplates(parentNode, graph, depth);
|
|
|
|
if (templates.length === 0) {
|
|
return {
|
|
accepted: false,
|
|
childNodes: [],
|
|
childEdges: [],
|
|
childNodeIds: [],
|
|
proposedChildCount: 0,
|
|
acceptedChildCount: 0,
|
|
rejectedChildren: [],
|
|
childQualitySummary: [],
|
|
reason:
|
|
"Decomposition stopped because no meaning-preserving child family was justified for this parent.",
|
|
};
|
|
}
|
|
|
|
const labelToId = new Map(
|
|
templates.map((template) => [
|
|
template.label,
|
|
buildDecompositionChildId(parentNode.id, template.label),
|
|
]),
|
|
);
|
|
const candidateNodes = templates.map((template) => {
|
|
const existing = findEquivalentDecompositionChild(
|
|
graph,
|
|
parentNode.id,
|
|
template.label,
|
|
template.description,
|
|
);
|
|
const dependsOn = (template.dependsOnLabels || [])
|
|
.map((label) => labelToId.get(label))
|
|
.filter(Boolean);
|
|
|
|
return (
|
|
existing || {
|
|
id: buildDecompositionChildId(parentNode.id, template.label),
|
|
label: template.label,
|
|
description: template.description,
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn,
|
|
affects: [],
|
|
parentId: parentNode.id,
|
|
childIds: [],
|
|
}
|
|
);
|
|
});
|
|
const childNodes = [];
|
|
const childEdges = [];
|
|
const childNodeIds = [];
|
|
const rejectedChildren = [];
|
|
const childQualitySummary = [];
|
|
|
|
for (const childNode of candidateNodes) {
|
|
const quality = assessChildUnknownQuality({
|
|
parentNode,
|
|
childNode,
|
|
siblingNodes: candidateNodes,
|
|
graph,
|
|
});
|
|
childQualitySummary.push({
|
|
nodeId: childNode.id,
|
|
label: childNode.label,
|
|
valid: quality.valid,
|
|
atomic: quality.atomic,
|
|
reasons: quality.reasons,
|
|
});
|
|
|
|
if (!quality.valid) {
|
|
rejectedChildren.push(
|
|
buildRejectedChildRecord(
|
|
childNode,
|
|
quality.reasons,
|
|
quality.compoundSignals,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
childNodeIds.push(childNode.id);
|
|
|
|
if ((graph.nodes || []).some((node) => node.id === childNode.id)) {
|
|
continue;
|
|
}
|
|
|
|
childNodes.push(childNode);
|
|
childEdges.push({
|
|
id: `e-${childNode.id.slice(0, 6)}-${parentNode.id.slice(0, 6)}`,
|
|
fromNodeId: childNode.id,
|
|
toNodeId: parentNode.id,
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description:
|
|
"This child unknown must be investigated before the broader parent explanation can be resolved.",
|
|
});
|
|
}
|
|
|
|
const acceptedChildCount = childNodeIds.length;
|
|
const proposedChildCount = candidateNodes.length;
|
|
|
|
if (acceptedChildCount < 2) {
|
|
return {
|
|
accepted: false,
|
|
childNodes: [],
|
|
childEdges: [],
|
|
childNodeIds: [],
|
|
proposedChildCount,
|
|
acceptedChildCount,
|
|
rejectedChildren,
|
|
childQualitySummary,
|
|
reason:
|
|
acceptedChildCount === 0
|
|
? "Decomposition stopped because all proposed children failed quality checks."
|
|
: "Decomposition stopped because fewer than two valid child unknowns remained after quality checks.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
accepted: true,
|
|
childNodes,
|
|
childEdges,
|
|
childNodeIds,
|
|
proposedChildCount,
|
|
acceptedChildCount,
|
|
rejectedChildren,
|
|
childQualitySummary,
|
|
reason:
|
|
childNodes.length > 0
|
|
? "Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question."
|
|
: "Reused existing decomposition children for the composite unknown before asking the next question.",
|
|
};
|
|
}
|
|
|
|
function findNodeById(graph, nodeId) {
|
|
return (graph.nodes || []).find((node) => node.id === nodeId) || null;
|
|
}
|
|
|
|
function isSelectableUnresolvedUnknown(graph, nodeId) {
|
|
const node = findNodeById(graph, nodeId);
|
|
return Boolean(
|
|
node &&
|
|
node.kind === "unknown" &&
|
|
!["resolved", "contradicted"].includes(node.status) &&
|
|
!(graph.resolvedNodeIds || []).includes(node.id),
|
|
);
|
|
}
|
|
|
|
function listUnresolvedUnknownCandidates(
|
|
graph,
|
|
resolvedCurrentTurnNodeIds = [],
|
|
) {
|
|
const resolvedCurrentTurnSet = new Set(resolvedCurrentTurnNodeIds || []);
|
|
|
|
return (graph.nodes || []).filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
!["resolved", "contradicted"].includes(node.status) &&
|
|
!(graph.resolvedNodeIds || []).includes(node.id) &&
|
|
!resolvedCurrentTurnSet.has(node.id),
|
|
);
|
|
}
|
|
|
|
function listEligibleUnknownCandidates(graph, resolvedCurrentTurnNodeIds = []) {
|
|
return listUnresolvedUnknownCandidates(
|
|
graph,
|
|
resolvedCurrentTurnNodeIds,
|
|
).filter(
|
|
(node) =>
|
|
(scoreUnknownCandidate(graph, node, graph.resolvedNodeIds || [])
|
|
.unresolvedParentUnknownCount ?? 0) === 0,
|
|
);
|
|
}
|
|
|
|
function selectOrderedSiblingCandidate(
|
|
graph,
|
|
candidateNodeIds = [],
|
|
resolvedCurrentTurnNodeIds = [],
|
|
) {
|
|
const candidates = candidateNodeIds
|
|
.map((nodeId) => findNodeById(graph, nodeId))
|
|
.filter(Boolean);
|
|
|
|
if (candidates.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const parentId = candidates[0]?.parentId ?? null;
|
|
if (!parentId || !candidates.every((node) => node.parentId === parentId)) {
|
|
return null;
|
|
}
|
|
|
|
const parentNode = findNodeById(graph, parentId);
|
|
const orderedIds = (parentNode?.childIds || []).filter((nodeId) =>
|
|
candidateNodeIds.includes(nodeId),
|
|
);
|
|
const fallbackOrderedIds = (graph.nodes || [])
|
|
.filter((node) => candidateNodeIds.includes(node.id))
|
|
.map((node) => node.id);
|
|
const orderedCandidateIds =
|
|
orderedIds.length > 0 ? orderedIds : fallbackOrderedIds;
|
|
|
|
const eligibleIds = new Set(
|
|
listEligibleUnknownCandidates(graph, resolvedCurrentTurnNodeIds).map(
|
|
(node) => node.id,
|
|
),
|
|
);
|
|
|
|
const selectedId = orderedCandidateIds.find((nodeId) =>
|
|
eligibleIds.has(nodeId),
|
|
);
|
|
if (!selectedId) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
status: "selected",
|
|
nodeId: selectedId,
|
|
reason:
|
|
"Resolved a sibling tie using the deterministic decomposition order after the update left multiple equally scored follow-up children.",
|
|
};
|
|
}
|
|
|
|
function selectedQuestionBelongsToChild(graph, selectedQuestion) {
|
|
if (!selectedQuestion?.nodeId) return false;
|
|
return Boolean(findNodeById(graph, selectedQuestion.nodeId)?.parentId);
|
|
}
|
|
|
|
function normaliseQuestionText(question) {
|
|
return normaliseText(String(question || "").replace(/\?/g, " "));
|
|
}
|
|
|
|
function semanticNodeSignature(node) {
|
|
return normaliseText(`${node?.label || ""} ${node?.description || ""}`);
|
|
}
|
|
|
|
function isStructurallyRepeatedQuestion({
|
|
previousQuestion,
|
|
previousNode,
|
|
nextQuestion,
|
|
nextNode,
|
|
nextQuestionFamily,
|
|
nextInvestigationStrategy,
|
|
}) {
|
|
if (!previousQuestion || !nextQuestion || !nextNode) {
|
|
return false;
|
|
}
|
|
|
|
const sameQuestion =
|
|
normaliseQuestionText(previousQuestion) ===
|
|
normaliseQuestionText(nextQuestion);
|
|
const previousSignature = previousNode
|
|
? semanticNodeSignature(previousNode)
|
|
: null;
|
|
const nextSignature = semanticNodeSignature(nextNode);
|
|
const sameSemanticTarget = previousSignature === nextSignature;
|
|
const sharedParent =
|
|
previousNode?.parentId &&
|
|
nextNode?.parentId &&
|
|
previousNode.parentId === nextNode.parentId;
|
|
const sameQuestionFamily = Boolean(previousNode && nextQuestionFamily);
|
|
const samePurpose = Boolean(nextQuestionFamily || nextInvestigationStrategy);
|
|
|
|
return (
|
|
sameQuestion &&
|
|
samePurpose &&
|
|
(sameSemanticTarget || sharedParent || sameQuestionFamily)
|
|
);
|
|
}
|
|
|
|
function buildRepeatedQuestionDiagnostics(nextNode) {
|
|
return `Rejected repeated follow-up for "${nextNode?.label || nextNode?.id || "unknown"}" because the previous answer did not justify asking the same structural question again while other eligible investigations may remain.`;
|
|
}
|
|
|
|
const ALLOWED_NODE_PATTERNS_BY_ACTIVE_PATTERN = {
|
|
decision: ["decision", "definition"],
|
|
explanation: ["explanation", "comparison", "definition"],
|
|
contradiction: ["contradiction", "comparison", "explanation", "definition"],
|
|
definition: ["definition"],
|
|
diagnosis: ["diagnosis", "comparison", "definition"],
|
|
comparison: ["comparison", "definition"],
|
|
prioritisation: ["prioritisation", "decision", "definition"],
|
|
};
|
|
|
|
function determineActiveReasoningPattern(node, graph) {
|
|
if (!node || !graph) {
|
|
return {
|
|
pattern: null,
|
|
reason: "No active reasoning pattern could be determined.",
|
|
};
|
|
}
|
|
|
|
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
|
|
let currentParentId = node.parentId;
|
|
while (currentParentId) {
|
|
const parentNode = nodesById.get(currentParentId);
|
|
if (!parentNode) break;
|
|
const parentSelection = selectReasoningPattern({ node: parentNode, graph });
|
|
if (parentSelection.pattern && parentSelection.pattern !== "definition") {
|
|
return {
|
|
pattern: parentSelection.pattern,
|
|
reason: `Inherited active reasoning pattern from parent node because ${parentSelection.reason}`,
|
|
};
|
|
}
|
|
currentParentId = parentNode.parentId;
|
|
}
|
|
|
|
const selection = selectReasoningPattern({ node, graph });
|
|
return {
|
|
pattern: selection.pattern,
|
|
reason: selection.reason,
|
|
};
|
|
}
|
|
|
|
function inferIntrinsicNodePattern(node, graph) {
|
|
const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`);
|
|
const observationCount = (graph.nodes || []).filter(
|
|
(candidate) =>
|
|
candidate.kind === "observation" && candidate.status === "supported",
|
|
).length;
|
|
|
|
if (
|
|
/\b(define|definition|meaning|term|terminology|boundaries)\b/.test(text)
|
|
) {
|
|
return "definition";
|
|
}
|
|
|
|
if (
|
|
/\b(contradiction|contradict|conflict|inconsistent|mismatch|opposing)\b/.test(
|
|
text,
|
|
)
|
|
) {
|
|
return "contradiction";
|
|
}
|
|
|
|
if (
|
|
/\b(two observations|measured|measurement|basis|scale|same period|different timing|comparable)\b/.test(
|
|
text,
|
|
)
|
|
) {
|
|
return "comparison";
|
|
}
|
|
|
|
if (
|
|
observationCount >= 2 &&
|
|
/\b(explain|explanation|what changed|difference between|divergence|moved differently)\b/.test(
|
|
text,
|
|
)
|
|
) {
|
|
return "explanation";
|
|
}
|
|
|
|
if (
|
|
/\b(genuine problem|who experiences|other people experience|how often this problem happens|what happens when this problem is not resolved|how people deal with this problem today|actively look for help|would pay to solve this problem|commercially justified|commercial justification|business case|value|demand|audience|customer|user|alternative|alternatives)\b/.test(
|
|
text,
|
|
)
|
|
) {
|
|
return "decision";
|
|
}
|
|
|
|
return selectReasoningPattern({ node, graph }).pattern;
|
|
}
|
|
|
|
function assessReasoningPatternCompatibility({ node, graph, activePattern }) {
|
|
if (!node || !activePattern) {
|
|
return {
|
|
compatible: true,
|
|
activePattern: activePattern ?? null,
|
|
nodePattern: null,
|
|
reason: "No active reasoning pattern constraint was applied.",
|
|
};
|
|
}
|
|
|
|
const nodePattern = inferIntrinsicNodePattern(node, graph);
|
|
const allowedPatterns = ALLOWED_NODE_PATTERNS_BY_ACTIVE_PATTERN[
|
|
activePattern
|
|
] ?? [activePattern];
|
|
const compatible = allowedPatterns.includes(nodePattern);
|
|
|
|
return {
|
|
compatible,
|
|
activePattern,
|
|
nodePattern,
|
|
allowedPatterns,
|
|
reason: compatible
|
|
? `Node remains compatible because ${nodePattern} is allowed during ${activePattern} reasoning.`
|
|
: `Node is incompatible because ${nodePattern} is not allowed during ${activePattern} reasoning.`,
|
|
};
|
|
}
|
|
|
|
function isExplicitComparisonFamilyUnknown(node) {
|
|
const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`);
|
|
return /\b(two observations|measured|measurement|basis|scale|same period|different timing|comparable)\b/.test(
|
|
text,
|
|
);
|
|
}
|
|
|
|
function buildCompatibilityFailure(node, compatibility, reason) {
|
|
return {
|
|
nodeId: node?.id ?? null,
|
|
label: node?.label ?? null,
|
|
activePattern: compatibility?.activePattern ?? null,
|
|
nodePattern: compatibility?.nodePattern ?? null,
|
|
allowedPatterns: compatibility?.allowedPatterns ?? [],
|
|
rejectionReason: reason || compatibility?.reason || null,
|
|
};
|
|
}
|
|
|
|
function buildRejectedSelectionDiagnostics({
|
|
node,
|
|
graph,
|
|
activePattern,
|
|
reason,
|
|
}) {
|
|
const compatibility = assessReasoningPatternCompatibility({
|
|
node,
|
|
graph,
|
|
activePattern,
|
|
});
|
|
|
|
return {
|
|
incompatibleNodeIds: node?.id ? [node.id] : [],
|
|
compatibilityFailures: [
|
|
buildCompatibilityFailure(node, compatibility, reason),
|
|
],
|
|
};
|
|
}
|
|
|
|
function selectPatternCompatibleUnknownCandidate({
|
|
graph,
|
|
resolvedNodeIds = [],
|
|
activePattern,
|
|
excludedNodeIds = [],
|
|
}) {
|
|
if (!activePattern) {
|
|
return selectActiveUnknownCandidate(graph, resolvedNodeIds);
|
|
}
|
|
|
|
const excluded = new Set(excludedNodeIds || []);
|
|
const incompatibleNodeIds = (graph.nodes || [])
|
|
.filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
!excluded.has(node.id) &&
|
|
!["resolved", "contradicted"].includes(node.status),
|
|
)
|
|
.filter(
|
|
(node) =>
|
|
!assessReasoningPatternCompatibility({
|
|
node,
|
|
graph,
|
|
activePattern,
|
|
}).compatible,
|
|
)
|
|
.map((node) => node.id);
|
|
|
|
return selectActiveUnknownCandidate(graph, [
|
|
...new Set([
|
|
...(resolvedNodeIds || []),
|
|
...incompatibleNodeIds,
|
|
...excludedNodeIds,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
function collectPatternCompatibilityDiagnostics({
|
|
graph,
|
|
activePattern,
|
|
candidateNodeIds = [],
|
|
}) {
|
|
if (!activePattern) {
|
|
return {
|
|
reasoningPatternValidation: {
|
|
activePattern: null,
|
|
valid: true,
|
|
reason: "No active reasoning pattern constraint was applied.",
|
|
},
|
|
patternCompatibleNodeCount: 0,
|
|
incompatibleNodeIds: [],
|
|
compatibilityFailures: [],
|
|
graphReasoningIntegrity: "not_applicable",
|
|
};
|
|
}
|
|
|
|
const candidateSet = new Set(candidateNodeIds || []);
|
|
const compatibilityFailures = (graph.nodes || [])
|
|
.filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
candidateSet.has(node.id) &&
|
|
!["resolved", "contradicted"].includes(node.status),
|
|
)
|
|
.map((node) => ({
|
|
node,
|
|
compatibility: assessReasoningPatternCompatibility({
|
|
node,
|
|
graph,
|
|
activePattern,
|
|
}),
|
|
}))
|
|
.filter(({ compatibility }) => !compatibility.compatible)
|
|
.map(({ node, compatibility }) =>
|
|
buildCompatibilityFailure(node, compatibility),
|
|
);
|
|
|
|
return {
|
|
reasoningPatternValidation: {
|
|
activePattern,
|
|
valid: compatibilityFailures.length === 0,
|
|
reason:
|
|
compatibilityFailures.length === 0
|
|
? `All selectable unknowns are compatible with ${activePattern} reasoning.`
|
|
: `Some selectable unknowns are incompatible with ${activePattern} reasoning.`,
|
|
},
|
|
patternCompatibleNodeCount:
|
|
(candidateNodeIds || []).length - compatibilityFailures.length,
|
|
incompatibleNodeIds: compatibilityFailures.map((failure) => failure.nodeId),
|
|
compatibilityFailures,
|
|
graphReasoningIntegrity:
|
|
compatibilityFailures.length === 0 ? "valid" : "invalid",
|
|
};
|
|
}
|
|
|
|
function selectDecompositionChildCandidate(
|
|
graph,
|
|
parentNodeId,
|
|
activePattern = null,
|
|
) {
|
|
const childCandidates = findDirectChildUnknowns(graph, parentNodeId)
|
|
.filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
!["resolved", "contradicted"].includes(node.status),
|
|
)
|
|
.filter((node) => {
|
|
if (
|
|
activePattern === "decision" &&
|
|
isExplicitComparisonFamilyUnknown(node)
|
|
) {
|
|
return false;
|
|
}
|
|
const compatibility = assessReasoningPatternCompatibility({
|
|
node,
|
|
graph,
|
|
activePattern,
|
|
});
|
|
return compatibility.compatible;
|
|
});
|
|
|
|
if (childCandidates.length === 0) {
|
|
return { status: "none", nodeId: null, tiedCandidateIds: [] };
|
|
}
|
|
|
|
const scored = childCandidates.map((node) => ({
|
|
node,
|
|
score:
|
|
scoreUnknownCandidate(graph, node, graph.resolvedNodeIds || []).score ??
|
|
Number.NEGATIVE_INFINITY,
|
|
}));
|
|
const topScore = Math.max(...scored.map((item) => item.score));
|
|
const top = scored.filter((item) => item.score === topScore);
|
|
|
|
if (top.length === 0) {
|
|
return { status: "none", nodeId: null, tiedCandidateIds: [] };
|
|
}
|
|
|
|
if (top.length > 1) {
|
|
return {
|
|
status: "ambiguous",
|
|
nodeId: null,
|
|
tiedCandidateIds: top.map((item) => item.node.id),
|
|
reason:
|
|
"Multiple decomposition children remain equally good next investigations.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "selected",
|
|
nodeId: top[0].node.id,
|
|
reason:
|
|
"Selected the strongest direct child investigation for a non-answerable parent unknown.",
|
|
};
|
|
}
|
|
|
|
function buildSelectedQuestionResult({
|
|
updatedSituationGraph,
|
|
deterministicSelection,
|
|
}) {
|
|
const selectedNode =
|
|
deterministicSelection?.status === "selected"
|
|
? findNodeById(updatedSituationGraph, deterministicSelection.nodeId)
|
|
: null;
|
|
const formulatedQuestion = selectedNode
|
|
? formulateQuestion({
|
|
node: selectedNode,
|
|
graph: updatedSituationGraph,
|
|
context: { selectionState: deterministicSelection },
|
|
})
|
|
: null;
|
|
|
|
const selectedQuestion =
|
|
deterministicSelection?.status === "ambiguous"
|
|
? {
|
|
id: "q_tie_resolution",
|
|
...formulateTieResolutionQuestion({ graph: updatedSituationGraph }),
|
|
nodeId: null,
|
|
tiedCandidateIds: deterministicSelection.tiedCandidateIds,
|
|
}
|
|
: deterministicSelection?.status === "selected" && formulatedQuestion
|
|
? {
|
|
nodeId: deterministicSelection.nodeId,
|
|
question:
|
|
formulatedQuestion.question || deterministicSelection.question,
|
|
reason: formulatedQuestion.reason,
|
|
strategy: formulatedQuestion.strategy,
|
|
investigationStrategy: formulatedQuestion.investigationStrategy,
|
|
reasoningPattern: formulatedQuestion.reasoningPattern,
|
|
reasoningPatternReason: formulatedQuestion.reasoningPatternReason,
|
|
questionFamily: formulatedQuestion.questionFamily,
|
|
allowedQuestionFamilies: formulatedQuestion.allowedQuestionFamilies,
|
|
rejectedQuestionFamilies:
|
|
formulatedQuestion.rejectedQuestionFamilies,
|
|
selectedQuestionTemplate:
|
|
formulatedQuestion.selectedQuestionTemplate,
|
|
questionComplexity: formulatedQuestion.questionComplexity,
|
|
plainLanguageNormalisations:
|
|
formulatedQuestion.plainLanguageNormalisations,
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
selectedNode,
|
|
formulatedQuestion,
|
|
selectedQuestion,
|
|
};
|
|
}
|
|
|
|
function resolveAmbiguousGraphBackedSelection({
|
|
graphSnapshot,
|
|
updatedSituationGraph,
|
|
deterministicSelection,
|
|
}) {
|
|
const orderedCandidateIds =
|
|
deterministicSelection?.displayOrder ||
|
|
deterministicSelection?.tiedCandidateIds ||
|
|
[];
|
|
|
|
for (const candidateNodeId of orderedCandidateIds) {
|
|
if (
|
|
!isSelectableUnresolvedUnknown(updatedSituationGraph, candidateNodeId)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const candidateResult = runDeterministicDecomposition({
|
|
graphSnapshot,
|
|
proposalSnapshot: {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
},
|
|
updatedSituationGraph: cloneJsonSafe(updatedSituationGraph),
|
|
reasoningResolution: { reasoningStateOverride: {} },
|
|
deterministicSelection: {
|
|
status: "selected",
|
|
nodeId: candidateNodeId,
|
|
reason:
|
|
"Selected this tied candidate for deterministic decomposition-based reselection.",
|
|
},
|
|
});
|
|
|
|
if (!candidateResult.success) {
|
|
continue;
|
|
}
|
|
|
|
const nextGraph = candidateResult.updatedSituationGraph;
|
|
nextGraph.reasoningState = buildReasoningState(nextGraph);
|
|
const nextSelection = isSelectableUnresolvedUnknown(
|
|
nextGraph,
|
|
candidateResult.selectedChildNodeId,
|
|
)
|
|
? {
|
|
status: "selected",
|
|
nodeId: candidateResult.selectedChildNodeId,
|
|
reason:
|
|
"Selected the preserved decomposition child after resolving an initial tie.",
|
|
}
|
|
: candidateResult.deterministicSelection;
|
|
|
|
const questionResult = buildSelectedQuestionResult({
|
|
updatedSituationGraph: nextGraph,
|
|
deterministicSelection: nextSelection,
|
|
});
|
|
|
|
if (questionResult.selectedQuestion?.question) {
|
|
nextGraph.activeUnknownNodeId =
|
|
nextSelection?.status === "selected" ? nextSelection.nodeId : null;
|
|
nextGraph.currentSummary = describeGraph(nextGraph);
|
|
|
|
return {
|
|
...candidateResult,
|
|
updatedSituationGraph: nextGraph,
|
|
deterministicSelection: nextSelection,
|
|
...questionResult,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function reseatSelectionAfterQuestionRejection({
|
|
graph,
|
|
deterministicSelection,
|
|
activePattern = null,
|
|
excludedNodeIds = [],
|
|
}) {
|
|
const nextSelection = activePattern
|
|
? selectPatternCompatibleUnknownCandidate({
|
|
graph,
|
|
resolvedNodeIds: graph.resolvedNodeIds || [],
|
|
activePattern,
|
|
excludedNodeIds,
|
|
})
|
|
: selectActiveUnknownCandidate(graph, [
|
|
...(graph.resolvedNodeIds || []),
|
|
...excludedNodeIds,
|
|
]);
|
|
|
|
return nextSelection?.status
|
|
? nextSelection
|
|
: { status: "none", nodeId: null };
|
|
}
|
|
|
|
export function determineGraphBackedQuestion({ situationGraph }) {
|
|
const graphSnapshot = cloneJsonSafe(situationGraph);
|
|
let updatedSituationGraph = cloneJsonSafe(situationGraph);
|
|
let deterministicSelection = selectActiveUnknownCandidate(
|
|
updatedSituationGraph,
|
|
updatedSituationGraph.resolvedNodeIds || [],
|
|
);
|
|
|
|
const decompositionResult = runDeterministicDecomposition({
|
|
graphSnapshot,
|
|
proposalSnapshot: {
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
},
|
|
updatedSituationGraph,
|
|
reasoningResolution: { reasoningStateOverride: {} },
|
|
deterministicSelection,
|
|
});
|
|
|
|
if (!decompositionResult.success) {
|
|
return decompositionResult;
|
|
}
|
|
|
|
updatedSituationGraph = decompositionResult.updatedSituationGraph;
|
|
updatedSituationGraph.reasoningState = buildReasoningState(
|
|
updatedSituationGraph,
|
|
);
|
|
deterministicSelection = isSelectableUnresolvedUnknown(
|
|
updatedSituationGraph,
|
|
decompositionResult.selectedChildNodeId,
|
|
)
|
|
? {
|
|
status: "selected",
|
|
nodeId: decompositionResult.selectedChildNodeId,
|
|
reason:
|
|
"Selected the preserved decomposition child because it remains the strongest independently answerable investigation.",
|
|
}
|
|
: selectActiveUnknownCandidate(
|
|
updatedSituationGraph,
|
|
updatedSituationGraph.resolvedNodeIds || [],
|
|
);
|
|
updatedSituationGraph.activeUnknownNodeId =
|
|
deterministicSelection?.status === "selected"
|
|
? deterministicSelection.nodeId
|
|
: null;
|
|
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
|
|
|
let questionResult = buildSelectedQuestionResult({
|
|
updatedSituationGraph,
|
|
deterministicSelection,
|
|
});
|
|
|
|
const initialQuestionRejected =
|
|
questionResult.selectedQuestion?.question &&
|
|
questionResult.selectedQuestion?.questionComplexity &&
|
|
questionResult.selectedQuestion.questionComplexity.acceptable === false;
|
|
|
|
if (
|
|
initialQuestionRejected &&
|
|
deterministicSelection?.status === "selected"
|
|
) {
|
|
deterministicSelection = reseatSelectionAfterQuestionRejection({
|
|
graph: updatedSituationGraph,
|
|
deterministicSelection,
|
|
excludedNodeIds: [deterministicSelection.nodeId],
|
|
});
|
|
questionResult = buildSelectedQuestionResult({
|
|
updatedSituationGraph,
|
|
deterministicSelection,
|
|
});
|
|
}
|
|
|
|
if (
|
|
deterministicSelection?.status === "ambiguous" &&
|
|
!questionResult.selectedQuestion?.question
|
|
) {
|
|
const reselectionResult = resolveAmbiguousGraphBackedSelection({
|
|
graphSnapshot,
|
|
updatedSituationGraph,
|
|
deterministicSelection,
|
|
});
|
|
|
|
if (reselectionResult) {
|
|
updatedSituationGraph = reselectionResult.updatedSituationGraph;
|
|
deterministicSelection = reselectionResult.deterministicSelection;
|
|
questionResult = reselectionResult;
|
|
}
|
|
}
|
|
|
|
const noQuestionReason = questionResult.selectedQuestion?.question
|
|
? null
|
|
: deterministicSelection?.status === "ambiguous"
|
|
? questionResult.selectedQuestion?.questionSuppressedReason ||
|
|
questionResult.selectedQuestion?.reason ||
|
|
"Eligible unresolved candidates remain tied after initial graph-backed selection."
|
|
: (updatedSituationGraph.nodes || []).some(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
!["resolved", "contradicted"].includes(node.status) &&
|
|
!(updatedSituationGraph.resolvedNodeIds || []).includes(node.id),
|
|
)
|
|
? "Compatible unresolved candidates remain, but none produced a valid graph-backed question."
|
|
: "No unresolved unknown candidates remain after initial graph construction.";
|
|
|
|
return {
|
|
success: true,
|
|
updatedSituationGraph,
|
|
deterministicSelection,
|
|
selectedQuestion: questionResult.selectedQuestion,
|
|
atomicityAssessment: decompositionResult.atomicityAssessment,
|
|
answerabilityAssessment: decompositionResult.answerabilityAssessment,
|
|
independentlyAnswerable:
|
|
decompositionResult.answerabilityAssessment?.independentlyAnswerable ??
|
|
null,
|
|
prerequisiteConceptCount:
|
|
decompositionResult.answerabilityAssessment?.prerequisiteConceptCount ??
|
|
null,
|
|
decompositionPerformed:
|
|
decompositionResult.decompositionAttempted &&
|
|
decompositionResult.decompositionAccepted,
|
|
decompositionAttempted: decompositionResult.decompositionAttempted,
|
|
decompositionTriggeredByAnswerability:
|
|
decompositionResult.decompositionTriggeredByAnswerability ?? false,
|
|
selectedContainerUnknown:
|
|
decompositionResult.selectedContainerUnknown ?? null,
|
|
selectedChildUnknown:
|
|
decompositionResult.selectedChildNodeId ??
|
|
(deterministicSelection?.status === "selected"
|
|
? deterministicSelection.nodeId
|
|
: null),
|
|
selectedUnknownBefore: decompositionResult.selectedUnknownBefore,
|
|
selectedUnknownAfter: deterministicSelection?.nodeId ?? null,
|
|
questionComplexityAssessment:
|
|
questionResult.formulatedQuestion?.questionComplexity ?? null,
|
|
noQuestionReason,
|
|
};
|
|
}
|
|
|
|
function runDeterministicDecomposition({
|
|
graphSnapshot,
|
|
proposalSnapshot,
|
|
updatedSituationGraph,
|
|
reasoningResolution,
|
|
deterministicSelection,
|
|
}) {
|
|
let workingGraph = updatedSituationGraph;
|
|
let workingSelection = deterministicSelection;
|
|
let workingProposal = proposalSnapshot;
|
|
let nextReasoningState = workingGraph.reasoningState;
|
|
let lastAtomicityAssessment = null;
|
|
let lastAnswerabilityAssessment = null;
|
|
let rootAtomicityAssessment = null;
|
|
let rootAnswerabilityAssessment = null;
|
|
let decompositionDepth = 0;
|
|
let decompositionAttempted = false;
|
|
let decompositionAccepted = false;
|
|
let proposedChildCount = 0;
|
|
let acceptedChildCount = 0;
|
|
let selectedChildNodeId = null;
|
|
let selectedUnknownBefore =
|
|
deterministicSelection?.status === "selected"
|
|
? deterministicSelection.nodeId
|
|
: null;
|
|
let decompositionStoppedReason = null;
|
|
let rejectedChildren = [];
|
|
let childQualitySummary = [];
|
|
let decompositionTriggeredByQuestionComplexity = false;
|
|
let decompositionTriggeredByAnswerability = false;
|
|
let selectedContainerUnknown = null;
|
|
let activeReasoningPattern = null;
|
|
let activeReasoningPatternReason = null;
|
|
let incompatibleNodeIds = [];
|
|
let compatibilityFailures = [];
|
|
let replacementActions = [];
|
|
|
|
while (workingSelection?.status === "selected" && workingSelection?.nodeId) {
|
|
const selectedNode = findNodeById(workingGraph, workingSelection.nodeId);
|
|
if (!selectedNode) {
|
|
decompositionStoppedReason =
|
|
"Selected node was not present in the updated graph.";
|
|
break;
|
|
}
|
|
|
|
if (!activeReasoningPattern) {
|
|
const activePatternSelection = determineActiveReasoningPattern(
|
|
selectedNode,
|
|
workingGraph,
|
|
);
|
|
activeReasoningPattern = activePatternSelection.pattern;
|
|
activeReasoningPatternReason = activePatternSelection.reason;
|
|
}
|
|
|
|
const selectedNodeCompatibility = assessReasoningPatternCompatibility({
|
|
node: selectedNode,
|
|
graph: workingGraph,
|
|
activePattern: activeReasoningPattern,
|
|
});
|
|
if (!selectedNodeCompatibility.compatible) {
|
|
incompatibleNodeIds = appendUniqueValue(
|
|
incompatibleNodeIds,
|
|
selectedNode.id,
|
|
);
|
|
compatibilityFailures.push(
|
|
buildCompatibilityFailure(
|
|
selectedNode,
|
|
selectedNodeCompatibility,
|
|
"Selected unknown violated the active reasoning pattern.",
|
|
),
|
|
);
|
|
const replacementSelection = selectPatternCompatibleUnknownCandidate({
|
|
graph: workingGraph,
|
|
resolvedNodeIds: workingGraph.resolvedNodeIds,
|
|
activePattern: activeReasoningPattern,
|
|
excludedNodeIds: [selectedNode.id],
|
|
});
|
|
if (replacementSelection?.status === "selected") {
|
|
replacementActions.push({
|
|
rejectedNodeId: selectedNode.id,
|
|
replacementNodeId: replacementSelection.nodeId,
|
|
reason:
|
|
"Replaced an incompatible active unknown with the next pattern-compatible candidate.",
|
|
});
|
|
workingSelection = replacementSelection;
|
|
continue;
|
|
}
|
|
decompositionStoppedReason =
|
|
"No reasoning-pattern-compatible unknown remained available for selection.";
|
|
break;
|
|
}
|
|
|
|
const atomicityAssessment = assessUnknownAtomicity({
|
|
node: selectedNode,
|
|
graph: workingGraph,
|
|
});
|
|
const answerabilityAssessment = assessUnknownAnswerability({
|
|
node: selectedNode,
|
|
graph: workingGraph,
|
|
});
|
|
lastAtomicityAssessment = atomicityAssessment;
|
|
lastAnswerabilityAssessment = answerabilityAssessment;
|
|
if (!rootAtomicityAssessment) {
|
|
rootAtomicityAssessment = atomicityAssessment;
|
|
}
|
|
if (!rootAnswerabilityAssessment) {
|
|
rootAnswerabilityAssessment = answerabilityAssessment;
|
|
}
|
|
|
|
const decompositionRequired =
|
|
atomicityAssessment.atomicity !== "atomic" ||
|
|
!answerabilityAssessment.independentlyAnswerable;
|
|
|
|
if (!decompositionRequired) {
|
|
selectedChildNodeId =
|
|
decompositionDepth > 0 || selectedNode.parentId
|
|
? selectedNode.id
|
|
: null;
|
|
decompositionStoppedReason =
|
|
decompositionDepth > 0
|
|
? "Selected child is atomic and directly answerable."
|
|
: "Selected unknown is already atomic.";
|
|
break;
|
|
}
|
|
|
|
if (!selectedContainerUnknown) {
|
|
selectedContainerUnknown = selectedNode.id;
|
|
}
|
|
if (!answerabilityAssessment.independentlyAnswerable) {
|
|
decompositionTriggeredByAnswerability = true;
|
|
}
|
|
|
|
if (hasExistingDecompositionChildren(workingGraph, selectedNode.id)) {
|
|
const childSelection = selectDecompositionChildCandidate(
|
|
workingGraph,
|
|
selectedNode.id,
|
|
activeReasoningPattern,
|
|
);
|
|
if (childSelection.status === "selected") {
|
|
workingSelection = childSelection;
|
|
decompositionStoppedReason =
|
|
"Selected child is atomic and directly answerable.";
|
|
selectedChildNodeId =
|
|
findNodeById(workingGraph, childSelection.nodeId)?.parentId ===
|
|
selectedNode.id
|
|
? childSelection.nodeId
|
|
: null;
|
|
break;
|
|
}
|
|
if (childSelection.status === "ambiguous") {
|
|
workingSelection = childSelection;
|
|
decompositionStoppedReason =
|
|
"Selected parent is not independently answerable and its existing child investigations are tied.";
|
|
break;
|
|
}
|
|
decompositionStoppedReason =
|
|
"Selected parent is not independently answerable, but no unresolved child investigation remained available.";
|
|
break;
|
|
}
|
|
|
|
if (decompositionDepth >= MAX_DECOMPOSITION_DEPTH) {
|
|
decompositionStoppedReason =
|
|
"Maximum decomposition depth reached before finding a smaller atomic child.";
|
|
break;
|
|
}
|
|
|
|
decompositionAttempted = true;
|
|
const decomposition = buildCompositeUnknownChildren(
|
|
selectedNode,
|
|
workingGraph,
|
|
decompositionDepth,
|
|
activeReasoningPattern,
|
|
);
|
|
|
|
proposedChildCount = decomposition.proposedChildCount;
|
|
acceptedChildCount = decomposition.acceptedChildCount;
|
|
rejectedChildren = mergeUniqueRecords(
|
|
rejectedChildren,
|
|
decomposition.rejectedChildren,
|
|
);
|
|
childQualitySummary = mergeUniqueRecords(
|
|
childQualitySummary,
|
|
decomposition.childQualitySummary,
|
|
);
|
|
|
|
if (!decomposition.accepted) {
|
|
decompositionStoppedReason = decomposition.reason;
|
|
break;
|
|
}
|
|
|
|
const previousGraph = cloneJsonSafe(workingGraph);
|
|
const previousProposal = cloneJsonSafe(workingProposal);
|
|
const previousReasoningState = cloneJsonSafe(nextReasoningState);
|
|
|
|
workingProposal.addedNodes.push(...decomposition.childNodes.map(cloneNode));
|
|
workingProposal.addedEdges.push(...decomposition.childEdges.map(cloneNode));
|
|
|
|
const applied = applyGraphUpdate(graphSnapshot, workingProposal);
|
|
if (!applied.success) {
|
|
return {
|
|
success: false,
|
|
stage: "application",
|
|
errors: applied.errors,
|
|
};
|
|
}
|
|
|
|
workingGraph = {
|
|
...graphSnapshot,
|
|
nodes: applied.nodes,
|
|
edges: applied.edges,
|
|
resolvedNodeIds: applied.resolvedNodeIds,
|
|
};
|
|
nextReasoningState = buildReasoningState(
|
|
workingGraph,
|
|
reasoningResolution.reasoningStateOverride,
|
|
);
|
|
workingGraph.reasoningState = nextReasoningState;
|
|
workingSelection = selectDecompositionChildCandidate(
|
|
workingGraph,
|
|
selectedNode.id,
|
|
activeReasoningPattern,
|
|
);
|
|
|
|
if (workingSelection?.status !== "selected") {
|
|
workingGraph = previousGraph;
|
|
workingProposal = previousProposal;
|
|
nextReasoningState = previousReasoningState;
|
|
workingSelection = selectActiveUnknownCandidate(
|
|
workingGraph,
|
|
workingGraph.resolvedNodeIds,
|
|
);
|
|
decompositionStoppedReason =
|
|
workingSelection?.status === "ambiguous"
|
|
? "Decomposition produced multiple equally valid children with no justified distinction."
|
|
: "No unresolved child remained selectable after decomposition.";
|
|
break;
|
|
}
|
|
|
|
if (
|
|
findNodeById(workingGraph, workingSelection.nodeId)?.parentId ===
|
|
selectedNode.id
|
|
) {
|
|
selectedChildNodeId = workingSelection.nodeId;
|
|
}
|
|
|
|
decompositionAccepted = true;
|
|
decompositionDepth += 1;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
updatedSituationGraph: workingGraph,
|
|
proposalSnapshot: workingProposal,
|
|
reasoningState: nextReasoningState,
|
|
deterministicSelection: workingSelection,
|
|
atomicityAssessment:
|
|
rootAtomicityAssessment ?? lastAtomicityAssessment ?? null,
|
|
answerabilityAssessment:
|
|
rootAnswerabilityAssessment ?? lastAnswerabilityAssessment ?? null,
|
|
decompositionDepth,
|
|
decompositionAttempted,
|
|
decompositionAccepted,
|
|
decompositionStoppedReason,
|
|
proposedChildCount,
|
|
acceptedChildCount,
|
|
rejectedChildren,
|
|
childQualitySummary,
|
|
selectedChildNodeId,
|
|
selectedUnknownBefore,
|
|
decompositionTriggeredByQuestionComplexity,
|
|
decompositionTriggeredByAnswerability,
|
|
selectedContainerUnknown,
|
|
activeReasoningPattern,
|
|
activeReasoningPatternReason,
|
|
incompatibleNodeIds,
|
|
compatibilityFailures,
|
|
replacementActions,
|
|
};
|
|
}
|
|
|
|
function isComparabilityQuestion(question) {
|
|
const text = String(question || "").toLowerCase();
|
|
return (
|
|
text.includes("same basis") ||
|
|
text.includes("same scale") ||
|
|
text.includes("same period")
|
|
);
|
|
}
|
|
|
|
function answerConfirmsComparability(answer) {
|
|
const text = String(answer || "").toLowerCase();
|
|
return (
|
|
/\byes\b/.test(text) &&
|
|
(text.includes("same accounting period") ||
|
|
text.includes("same management accounts") ||
|
|
text.includes("same basis") ||
|
|
text.includes("same scale") ||
|
|
text.includes("both figures cover the same"))
|
|
);
|
|
}
|
|
|
|
function normaliseSemanticText(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function semanticContentTokens(value) {
|
|
const stopWords = new Set([
|
|
"the",
|
|
"and",
|
|
"for",
|
|
"that",
|
|
"this",
|
|
"with",
|
|
"from",
|
|
"into",
|
|
"than",
|
|
"then",
|
|
"they",
|
|
"them",
|
|
"their",
|
|
"there",
|
|
"about",
|
|
"would",
|
|
"could",
|
|
"should",
|
|
"because",
|
|
"being",
|
|
"been",
|
|
"have",
|
|
"has",
|
|
"had",
|
|
"were",
|
|
"what",
|
|
"when",
|
|
"where",
|
|
"which",
|
|
"while",
|
|
"mainly",
|
|
"roughly",
|
|
"specifically",
|
|
"directly",
|
|
"user",
|
|
]);
|
|
|
|
return normaliseSemanticText(value)
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
.split(" ")
|
|
.filter((token) => token.length > 2 && !stopWords.has(token));
|
|
}
|
|
|
|
function semanticOverlapRatio(sourceText, candidateText) {
|
|
const source = new Set(semanticContentTokens(sourceText));
|
|
const candidate = new Set(semanticContentTokens(candidateText));
|
|
|
|
if (candidate.size === 0) return 1;
|
|
|
|
const overlap = [...candidate].filter((token) => source.has(token)).length;
|
|
return overlap / candidate.size;
|
|
}
|
|
|
|
function rawAnswerSupportsUnclassifiedMeaning(answer, userSupportedMeaning) {
|
|
const overlapRatio = semanticOverlapRatio(answer, userSupportedMeaning);
|
|
const overlappingTokens = semanticContentTokens(userSupportedMeaning).filter(
|
|
(token) => semanticContentTokens(answer).includes(token),
|
|
).length;
|
|
|
|
return overlapRatio >= 0.4 || overlappingTokens >= 3;
|
|
}
|
|
|
|
function hasConditionalQualification(text) {
|
|
const value = normaliseSemanticText(text);
|
|
return (
|
|
value.includes("might") ||
|
|
value.includes("normally") ||
|
|
value.includes("for the right opportunity") ||
|
|
value.includes("depends") ||
|
|
value.includes("conditional") ||
|
|
value.includes("under specific")
|
|
);
|
|
}
|
|
|
|
function containsConstraintBoundaryLanguage(text) {
|
|
const value = normaliseSemanticText(text);
|
|
return (
|
|
value.includes("hard constraint") ||
|
|
value.includes("constraint") ||
|
|
value.includes("non negotiable") ||
|
|
value.includes("non-negotiable") ||
|
|
value.includes("preference") ||
|
|
value.includes("trade off") ||
|
|
value.includes("trade-off")
|
|
);
|
|
}
|
|
|
|
function containsWeakeningOfHardConstraint(text) {
|
|
const value = normaliseSemanticText(text);
|
|
return (
|
|
value.includes("preference") ||
|
|
value.includes("trade off") ||
|
|
value.includes("trade-off") ||
|
|
value.includes("not a hard constraint") ||
|
|
value.includes("rather than a hard constraint")
|
|
);
|
|
}
|
|
|
|
function proposalResolutionSummary(proposal) {
|
|
const resolved =
|
|
proposal.resolvedUnknownNodeIds.length > 0 ||
|
|
proposal.updatedNodes.some((update) => update.newStatus === "resolved");
|
|
const proposalText = normaliseSemanticText(
|
|
[
|
|
...(proposal.updatedNodes || []).flatMap((update) => [
|
|
update.reason,
|
|
update.newValue,
|
|
]),
|
|
proposal.selectedQuestion?.question,
|
|
proposal.selectedQuestion?.reason,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
);
|
|
|
|
return { resolved, proposalText };
|
|
}
|
|
|
|
function resolvedProposalMeaningText(proposal) {
|
|
return normaliseSemanticText(
|
|
(proposal.updatedNodes || [])
|
|
.filter((update) => update.newStatus === "resolved")
|
|
.map((update) => update.newValue)
|
|
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
.join(" "),
|
|
);
|
|
}
|
|
|
|
function mentionsHardConstraint(text) {
|
|
return (
|
|
text.includes("hard constraint") ||
|
|
text.includes("non-negotiable") ||
|
|
text.includes("dont want any increase in risk") ||
|
|
text.includes("do not want any increase in risk")
|
|
);
|
|
}
|
|
|
|
function mentionsNegatedHardConstraint(text) {
|
|
return (
|
|
text.includes("rather than a hard constraint") ||
|
|
text.includes("not a hard constraint") ||
|
|
text.includes("not an absolute constraint") ||
|
|
text.includes("preference rather than a hard constraint")
|
|
);
|
|
}
|
|
|
|
function hasDefaultPreferenceSignal(text) {
|
|
return (
|
|
text.includes("preference") ||
|
|
text.includes("normally") ||
|
|
text.includes("default preference") ||
|
|
text.includes("would usually") ||
|
|
text.includes("tend to")
|
|
);
|
|
}
|
|
|
|
function hasExceptionOrOverrideSignal(text) {
|
|
return (
|
|
text.includes(" but ") ||
|
|
text.includes(" if ") ||
|
|
text.includes("override") ||
|
|
text.includes("overridden") ||
|
|
text.includes("willing to accept") ||
|
|
text.includes("willingness to accept") ||
|
|
text.includes("accept some risk")
|
|
);
|
|
}
|
|
|
|
function deriveAnswerMeaningProfile(userSupportedMeaning) {
|
|
const meaningText = normaliseSemanticText(userSupportedMeaning);
|
|
|
|
if (
|
|
meaningText.includes("not really sure") ||
|
|
meaningText.includes("not sure") ||
|
|
meaningText.includes("unsure") ||
|
|
meaningText.includes("do not know") ||
|
|
meaningText.includes("don't know")
|
|
) {
|
|
return {
|
|
category: "uncertain",
|
|
resolutionGuidance: "must_remain_unresolved",
|
|
};
|
|
}
|
|
|
|
const negatedHardConstraint = mentionsNegatedHardConstraint(meaningText);
|
|
const affirmativeHardConstraint =
|
|
mentionsHardConstraint(meaningText) && !negatedHardConstraint;
|
|
const conditionalPreferenceStructure =
|
|
(hasDefaultPreferenceSignal(meaningText) &&
|
|
hasExceptionOrOverrideSignal(meaningText)) ||
|
|
(negatedHardConstraint && hasExceptionOrOverrideSignal(meaningText)) ||
|
|
hasConditionalQualification(meaningText);
|
|
|
|
if (conditionalPreferenceStructure) {
|
|
return {
|
|
category: "conditional_tradeoff",
|
|
resolutionGuidance: "may_resolve",
|
|
};
|
|
}
|
|
|
|
if (affirmativeHardConstraint) {
|
|
return {
|
|
category: "explicit_hard_constraint",
|
|
resolutionGuidance: "must_resolve",
|
|
};
|
|
}
|
|
|
|
if (
|
|
meaningText.includes("matters more") ||
|
|
meaningText.includes("more important") ||
|
|
meaningText.includes("higher priority") ||
|
|
meaningText.includes("greater relative importance") ||
|
|
meaningText.includes("relative importance")
|
|
) {
|
|
return {
|
|
category: "relative_priority_only",
|
|
resolutionGuidance: "must_remain_unresolved",
|
|
};
|
|
}
|
|
|
|
return {
|
|
category: "other",
|
|
resolutionGuidance: null,
|
|
};
|
|
}
|
|
|
|
function validateAnswerMeaningCompatibilityWithRawAnswer({ answer, proposal }) {
|
|
if (!answer || !proposal.answerMeaning) return [];
|
|
|
|
const errors = [];
|
|
const rawAnswerProfile = deriveAnswerMeaningProfile(answer);
|
|
const supportedMeaningProfile = deriveAnswerMeaningProfile(
|
|
proposal.answerMeaning.userSupportedMeaning,
|
|
);
|
|
const supportedMeaningText = normaliseSemanticText(
|
|
proposal.answerMeaning.userSupportedMeaning,
|
|
);
|
|
|
|
if (rawAnswerProfile.category === "relative_priority_only") {
|
|
if (supportedMeaningProfile.category !== "relative_priority_only") {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning introduces a stronger reasoning category than the raw answer establishes.",
|
|
);
|
|
}
|
|
|
|
if (containsConstraintBoundaryLanguage(supportedMeaningText)) {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning introduces an unsupported constraint or preference/trade-off distinction not present in the raw answer.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rawAnswerProfile.category === "conditional_tradeoff") {
|
|
if (supportedMeaningProfile.category !== "conditional_tradeoff") {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning loses the raw answer's conditional trade-off structure.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rawAnswerProfile.category === "uncertain") {
|
|
if (supportedMeaningProfile.category !== "uncertain") {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning overstates a raw answer that remains uncertain.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rawAnswerProfile.category === "explicit_hard_constraint") {
|
|
if (supportedMeaningProfile.category !== "explicit_hard_constraint") {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning weakens a raw answer that explicitly states a hard constraint.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rawAnswerProfile.category === "other") {
|
|
if (supportedMeaningProfile.category !== "other") {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning introduces a stronger reasoning category than the raw answer establishes.",
|
|
);
|
|
} else if (
|
|
!rawAnswerSupportsUnclassifiedMeaning(
|
|
answer,
|
|
proposal.answerMeaning.userSupportedMeaning,
|
|
)
|
|
) {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning introduces unsupported meaning beyond what the raw answer itself states.",
|
|
);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateAnswerMeaningAlignment(proposal) {
|
|
if (!proposal.answerMeaning) return [];
|
|
|
|
const errors = [];
|
|
const { userSupportedMeaning } = proposal.answerMeaning;
|
|
const meaningText = normaliseSemanticText(userSupportedMeaning);
|
|
const { resolved, proposalText } = proposalResolutionSummary(proposal);
|
|
const derivedProfile = deriveAnswerMeaningProfile(userSupportedMeaning);
|
|
const supportCategory = derivedProfile.category;
|
|
const resolutionGuidance = derivedProfile.resolutionGuidance;
|
|
|
|
if (resolutionGuidance === "must_remain_unresolved" && resolved) {
|
|
errors.push(
|
|
"Proposal resolves an unknown even though answerMeaning says the user's answer must remain unresolved.",
|
|
);
|
|
}
|
|
|
|
if (supportCategory === "relative_priority_only") {
|
|
if (containsConstraintBoundaryLanguage(meaningText)) {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning for relative_priority_only must not introduce a constraint or preference/trade-off judgement.",
|
|
);
|
|
}
|
|
|
|
if (containsConstraintBoundaryLanguage(proposalText)) {
|
|
errors.push(
|
|
"Proposal introduces unsupported constraint-boundary interpretation from a relative-priority answer.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (supportCategory === "conditional_tradeoff") {
|
|
if (!hasConditionalQualification(meaningText)) {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning for conditional_tradeoff must preserve the user's qualification or condition.",
|
|
);
|
|
}
|
|
|
|
if (resolved && !hasConditionalQualification(proposalText)) {
|
|
errors.push(
|
|
"Proposal resolves a conditional trade-off without preserving its conditional qualification in the proposed change.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (supportCategory === "uncertain") {
|
|
if (containsConstraintBoundaryLanguage(proposalText)) {
|
|
errors.push(
|
|
"Proposal introduces a stronger interpretation even though answerMeaning marks the answer as uncertain.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (supportCategory === "explicit_hard_constraint") {
|
|
if (!resolved && resolutionGuidance === "must_resolve") {
|
|
errors.push(
|
|
"Proposal leaves an explicitly stated hard constraint unresolved.",
|
|
);
|
|
}
|
|
|
|
if (containsWeakeningOfHardConstraint(proposalText)) {
|
|
errors.push(
|
|
"Proposal weakens an explicitly stated hard constraint into a preference or trade-off.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (supportCategory === "other") {
|
|
const resolvedMeaningText = resolvedProposalMeaningText(proposal);
|
|
|
|
if (
|
|
resolved &&
|
|
resolvedMeaningText &&
|
|
!rawAnswerSupportsUnclassifiedMeaning(
|
|
proposal.answerMeaning.userSupportedMeaning,
|
|
resolvedMeaningText,
|
|
)
|
|
) {
|
|
errors.push(
|
|
"Proposal cannot resolve beyond an unclassified answer by introducing unsupported stronger meaning than answerMeaning.userSupportedMeaning establishes.",
|
|
);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function deriveReasoningStateOverride({
|
|
graph,
|
|
previousQuestion,
|
|
answer,
|
|
resolvedUnknownNodeIds,
|
|
}) {
|
|
const previousReasoningState = buildReasoningState(graph);
|
|
const previousComparabilityStatus =
|
|
previousReasoningState.comparabilityStatus ?? null;
|
|
|
|
if (
|
|
previousComparabilityStatus === "uncertain" &&
|
|
isComparabilityQuestion(previousQuestion) &&
|
|
answerConfirmsComparability(answer)
|
|
) {
|
|
return {
|
|
reasoningStateOverride: {
|
|
comparabilityStatus: "confirmed",
|
|
comparabilityReason:
|
|
"Comparability was confirmed by the user answer covering the same period and source basis.",
|
|
comparabilityEvidence: resolvedUnknownNodeIds,
|
|
},
|
|
resolvedReasoningNodeIds: [COMPARABILITY_REASONING_NODE_ID],
|
|
previousReasoningState,
|
|
};
|
|
}
|
|
|
|
return {
|
|
reasoningStateOverride: {},
|
|
resolvedReasoningNodeIds: [],
|
|
previousReasoningState,
|
|
};
|
|
}
|
|
|
|
export function applyValidatedProposal({
|
|
situationGraph,
|
|
proposal,
|
|
previousQuestion = null,
|
|
answer = null,
|
|
}) {
|
|
const graphValidation = situationGraphSchema.safeParse(situationGraph);
|
|
const proposalValidation = graphUpdateSchema.safeParse(proposal);
|
|
|
|
const existingGraphReferenceValidation = graphValidation.success
|
|
? validateGraphReferences(situationGraph)
|
|
: null;
|
|
|
|
const existingDuplicateNodeIds = graphValidation.success
|
|
? detectDuplicateNodeIds(situationGraph.nodes)
|
|
: [];
|
|
const existingDuplicateEdgeIds = graphValidation.success
|
|
? collectDuplicateEdgeIds(situationGraph.edges)
|
|
: [];
|
|
|
|
if (
|
|
!graphValidation.success ||
|
|
!existingGraphReferenceValidation?.valid ||
|
|
existingDuplicateNodeIds.length > 0 ||
|
|
existingDuplicateEdgeIds.length > 0
|
|
) {
|
|
return {
|
|
success: false,
|
|
stage: "graph_validation",
|
|
errors: [
|
|
...(!graphValidation.success
|
|
? zodIssuesToErrors(graphValidation.error)
|
|
: []),
|
|
...(!existingGraphReferenceValidation?.valid
|
|
? existingGraphReferenceValidation.errors
|
|
: []),
|
|
...existingDuplicateNodeIds.map(
|
|
({ nodeId, count }) =>
|
|
`Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
|
),
|
|
...existingDuplicateEdgeIds.map(
|
|
({ edgeId, count }) =>
|
|
`Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
|
|
),
|
|
],
|
|
};
|
|
}
|
|
|
|
if (!proposalValidation.success) {
|
|
return {
|
|
success: false,
|
|
stage: "proposal_compatibility",
|
|
errors: zodIssuesToErrors(proposalValidation.error),
|
|
};
|
|
}
|
|
|
|
const reconciledProposal = reconcileResolutionSemantics(
|
|
situationGraph,
|
|
proposalValidation.data,
|
|
);
|
|
const validatedProposal = reconciledProposal.proposal;
|
|
const proposalCompatibilityErrors = [];
|
|
proposalCompatibilityErrors.push(...reconciledProposal.errors);
|
|
const proposalGraphValidation = validateGraphUpdate(
|
|
situationGraph,
|
|
validatedProposal,
|
|
);
|
|
|
|
if (!proposalGraphValidation.valid) {
|
|
proposalCompatibilityErrors.push(...proposalGraphValidation.errors);
|
|
}
|
|
|
|
const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id));
|
|
const reachableNodeIds = new Set([
|
|
...situationGraph.nodes.map((node) => node.id),
|
|
...validatedProposal.addedNodes.map((node) => node.id),
|
|
]);
|
|
const addedEdgeDuplicateIds = collectDuplicateEdgeIds(
|
|
validatedProposal.addedEdges,
|
|
);
|
|
proposalCompatibilityErrors.push(
|
|
...addedEdgeDuplicateIds.map(
|
|
({ edgeId, count }) =>
|
|
`Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`,
|
|
),
|
|
);
|
|
|
|
for (const edge of validatedProposal.addedEdges) {
|
|
if (existingEdgeIds.has(edge.id)) {
|
|
proposalCompatibilityErrors.push(
|
|
`Cannot add edge with duplicate ID: "${edge.id}"`,
|
|
);
|
|
}
|
|
if (!reachableNodeIds.has(edge.fromNodeId)) {
|
|
proposalCompatibilityErrors.push(
|
|
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
|
|
);
|
|
}
|
|
if (!reachableNodeIds.has(edge.toNodeId)) {
|
|
proposalCompatibilityErrors.push(
|
|
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const removedEdgeIds = new Set(validatedProposal.removedEdgeIds);
|
|
for (const edgeId of removedEdgeIds) {
|
|
if (!existingEdgeIds.has(edgeId)) {
|
|
proposalCompatibilityErrors.push(
|
|
`Cannot remove non-existent edge: "${edgeId}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const combinedNodeDuplicates = detectDuplicateNodeIds([
|
|
...situationGraph.nodes,
|
|
...validatedProposal.addedNodes,
|
|
]);
|
|
proposalCompatibilityErrors.push(
|
|
...combinedNodeDuplicates.map(
|
|
({ nodeId, count }) =>
|
|
`Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
|
),
|
|
);
|
|
|
|
proposalCompatibilityErrors.push(
|
|
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
|
|
);
|
|
proposalCompatibilityErrors.push(
|
|
...validateAddedUnknowns(situationGraph, validatedProposal, validatedProposal.answerMeaning),
|
|
);
|
|
|
|
const selectedQuestionValidation = validateSelectedQuestion(
|
|
situationGraph,
|
|
validatedProposal,
|
|
);
|
|
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
|
|
proposalCompatibilityErrors.push(
|
|
...validateAnswerMeaningCompatibilityWithRawAnswer({
|
|
answer,
|
|
proposal: validatedProposal,
|
|
}),
|
|
);
|
|
proposalCompatibilityErrors.push(
|
|
...validateAnswerMeaningAlignment(validatedProposal),
|
|
);
|
|
proposalCompatibilityErrors.push(
|
|
...validateQuestionSelectionRequirement(situationGraph, validatedProposal),
|
|
);
|
|
|
|
if (proposalCompatibilityErrors.length > 0) {
|
|
return {
|
|
success: false,
|
|
stage: "proposal_compatibility",
|
|
errors: proposalCompatibilityErrors,
|
|
};
|
|
}
|
|
|
|
const graphSnapshot = cloneJsonSafe(situationGraph);
|
|
const proposalSnapshot = cloneJsonSafe(validatedProposal);
|
|
const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null;
|
|
const previousActiveUnknownNode = previousActiveUnknownNodeId
|
|
? findNodeById(graphSnapshot, previousActiveUnknownNodeId)
|
|
: null;
|
|
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
|
|
const reasoningResolution = deriveReasoningStateOverride({
|
|
graph: graphSnapshot,
|
|
previousQuestion,
|
|
answer,
|
|
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
|
});
|
|
|
|
const provisionalApplied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
|
|
if (!provisionalApplied.success) {
|
|
return {
|
|
success: false,
|
|
stage: "application",
|
|
errors: provisionalApplied.errors,
|
|
};
|
|
}
|
|
const provisionalGraph = {
|
|
...graphSnapshot,
|
|
nodes: provisionalApplied.nodes,
|
|
edges: provisionalApplied.edges,
|
|
resolvedNodeIds: provisionalApplied.resolvedNodeIds,
|
|
};
|
|
provisionalGraph.reasoningState = buildReasoningState(
|
|
provisionalGraph,
|
|
reasoningResolution.reasoningStateOverride,
|
|
);
|
|
const relationshipAssessment =
|
|
classifyObservationRelationship(provisionalGraph);
|
|
const emergentReasoningUnknown = buildEmergentReasoningUnknown(
|
|
provisionalGraph,
|
|
relationshipAssessment,
|
|
);
|
|
|
|
if (emergentReasoningUnknown?.created) {
|
|
proposalSnapshot.addedNodes.push(emergentReasoningUnknown.node);
|
|
proposalSnapshot.addedEdges.push(...emergentReasoningUnknown.edges);
|
|
}
|
|
|
|
let applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
|
|
if (!applied.success) {
|
|
return {
|
|
success: false,
|
|
stage: "application",
|
|
errors: applied.errors,
|
|
};
|
|
}
|
|
|
|
let updatedSituationGraph = {
|
|
...graphSnapshot,
|
|
nodes: applied.nodes,
|
|
edges: applied.edges,
|
|
resolvedNodeIds: applied.resolvedNodeIds,
|
|
};
|
|
let nextReasoningState = buildReasoningState(
|
|
updatedSituationGraph,
|
|
reasoningResolution.reasoningStateOverride,
|
|
);
|
|
updatedSituationGraph.reasoningState = nextReasoningState;
|
|
|
|
const activeUnknownWasResolved =
|
|
previousActiveUnknownNodeId != null &&
|
|
updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId);
|
|
|
|
let newActiveUnknownNodeId = previousActiveUnknownNodeId;
|
|
if (activeUnknownWasResolved) {
|
|
newActiveUnknownNodeId = null;
|
|
}
|
|
|
|
if (validatedProposal.selectedQuestion?.nodeId) {
|
|
newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId;
|
|
}
|
|
|
|
const remainingUnknownExists =
|
|
newActiveUnknownNodeId != null &&
|
|
updatedSituationGraph.nodes.some(
|
|
(node) =>
|
|
node.id === newActiveUnknownNodeId &&
|
|
node.kind === "unknown" &&
|
|
!updatedSituationGraph.resolvedNodeIds.includes(node.id),
|
|
);
|
|
|
|
if (!remainingUnknownExists) {
|
|
newActiveUnknownNodeId =
|
|
selectActiveUnknownCandidate(
|
|
updatedSituationGraph,
|
|
updatedSituationGraph.resolvedNodeIds,
|
|
)?.nodeId ?? null;
|
|
}
|
|
|
|
let deterministicSelection = selectActiveUnknownCandidate(
|
|
updatedSituationGraph,
|
|
updatedSituationGraph.resolvedNodeIds,
|
|
);
|
|
|
|
const decompositionResult = runDeterministicDecomposition({
|
|
graphSnapshot,
|
|
proposalSnapshot,
|
|
updatedSituationGraph,
|
|
reasoningResolution,
|
|
deterministicSelection,
|
|
});
|
|
|
|
if (!decompositionResult.success) {
|
|
return decompositionResult;
|
|
}
|
|
|
|
updatedSituationGraph = decompositionResult.updatedSituationGraph;
|
|
nextReasoningState = decompositionResult.reasoningState;
|
|
deterministicSelection = decompositionResult.deterministicSelection;
|
|
|
|
const propagationResult = propagateResolvedChildEvidence({
|
|
updatedSituationGraph,
|
|
proposalSnapshot: decompositionResult.proposalSnapshot,
|
|
});
|
|
|
|
updatedSituationGraph = propagationResult.graph;
|
|
nextReasoningState = buildReasoningState(
|
|
updatedSituationGraph,
|
|
reasoningResolution.reasoningStateOverride,
|
|
);
|
|
updatedSituationGraph.reasoningState = nextReasoningState;
|
|
const resolvedCurrentTurnNodeIds = [
|
|
...new Set(proposalSnapshot.resolvedUnknownNodeIds || []),
|
|
];
|
|
let postPropagationIncompatibleNodeIds = [];
|
|
let postPropagationCompatibilityFailures = [];
|
|
let postPropagationReplacementActions = [];
|
|
let activeUnknownIncompatibleNodeIds = [];
|
|
let activeUnknownCompatibilityFailures = [];
|
|
let activeUnknownReplacementActions = [];
|
|
const preservedSelectedChildNode = isSelectableUnresolvedUnknown(
|
|
updatedSituationGraph,
|
|
decompositionResult.selectedChildNodeId,
|
|
)
|
|
? findNodeById(
|
|
updatedSituationGraph,
|
|
decompositionResult.selectedChildNodeId,
|
|
)
|
|
: null;
|
|
const preservedSelectedChildCompatibility = preservedSelectedChildNode
|
|
? assessReasoningPatternCompatibility({
|
|
node: preservedSelectedChildNode,
|
|
graph: updatedSituationGraph,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
})
|
|
: null;
|
|
|
|
if (
|
|
preservedSelectedChildNode &&
|
|
preservedSelectedChildCompatibility?.compatible
|
|
) {
|
|
deterministicSelection = {
|
|
status: "selected",
|
|
nodeId: decompositionResult.selectedChildNodeId,
|
|
reason:
|
|
"Preserved the selected decomposition child because it remains unresolved and reasoning-pattern-compatible after propagation.",
|
|
};
|
|
} else {
|
|
if (preservedSelectedChildNode) {
|
|
const rejectionDiagnostics = buildRejectedSelectionDiagnostics({
|
|
node: preservedSelectedChildNode,
|
|
graph: updatedSituationGraph,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
reason:
|
|
"Preserved decomposition child violated the active reasoning pattern after propagation.",
|
|
});
|
|
postPropagationIncompatibleNodeIds =
|
|
rejectionDiagnostics.incompatibleNodeIds;
|
|
postPropagationCompatibilityFailures =
|
|
rejectionDiagnostics.compatibilityFailures;
|
|
}
|
|
|
|
deterministicSelection = selectPatternCompatibleUnknownCandidate({
|
|
graph: updatedSituationGraph,
|
|
resolvedNodeIds: updatedSituationGraph.resolvedNodeIds,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
excludedNodeIds: preservedSelectedChildNode
|
|
? [preservedSelectedChildNode.id]
|
|
: [],
|
|
});
|
|
|
|
if (
|
|
preservedSelectedChildNode &&
|
|
deterministicSelection?.status === "selected"
|
|
) {
|
|
postPropagationReplacementActions.push({
|
|
rejectedNodeId: preservedSelectedChildNode.id,
|
|
replacementNodeId: deterministicSelection.nodeId,
|
|
reason:
|
|
"Replaced a preserved decomposition child that violated reasoning-pattern consistency.",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (deterministicSelection?.status === "ambiguous") {
|
|
const orderedSiblingSelection = selectOrderedSiblingCandidate(
|
|
updatedSituationGraph,
|
|
deterministicSelection.tiedCandidateIds || [],
|
|
resolvedCurrentTurnNodeIds,
|
|
);
|
|
|
|
if (orderedSiblingSelection) {
|
|
deterministicSelection = orderedSiblingSelection;
|
|
}
|
|
}
|
|
|
|
const carriedActiveUnknownNode = previousActiveUnknownNodeId
|
|
? findNodeById(updatedSituationGraph, previousActiveUnknownNodeId)
|
|
: null;
|
|
const carriedActiveUnknownStillUnresolved = Boolean(
|
|
carriedActiveUnknownNode &&
|
|
carriedActiveUnknownNode.kind === "unknown" &&
|
|
!["resolved", "contradicted"].includes(carriedActiveUnknownNode.status) &&
|
|
!updatedSituationGraph.resolvedNodeIds.includes(
|
|
carriedActiveUnknownNode.id,
|
|
),
|
|
);
|
|
|
|
if (
|
|
carriedActiveUnknownStillUnresolved &&
|
|
decompositionResult.activeReasoningPattern
|
|
) {
|
|
const carriedActiveCompatibility = assessReasoningPatternCompatibility({
|
|
node: carriedActiveUnknownNode,
|
|
graph: updatedSituationGraph,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
});
|
|
|
|
if (!carriedActiveCompatibility.compatible) {
|
|
activeUnknownIncompatibleNodeIds = [carriedActiveUnknownNode.id];
|
|
activeUnknownCompatibilityFailures = [
|
|
buildCompatibilityFailure(
|
|
carriedActiveUnknownNode,
|
|
carriedActiveCompatibility,
|
|
"Carried active unknown violated the active reasoning pattern and was not retained for investigation.",
|
|
),
|
|
];
|
|
|
|
if (
|
|
deterministicSelection?.status === "selected" &&
|
|
deterministicSelection.nodeId !== carriedActiveUnknownNode.id
|
|
) {
|
|
activeUnknownReplacementActions = [
|
|
{
|
|
rejectedNodeId: carriedActiveUnknownNode.id,
|
|
replacementNodeId: deterministicSelection.nodeId,
|
|
reason:
|
|
"Replaced an incompatible carried active unknown with a pattern-compatible investigation target.",
|
|
},
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
const unresolvedCandidates = listUnresolvedUnknownCandidates(
|
|
updatedSituationGraph,
|
|
resolvedCurrentTurnNodeIds,
|
|
);
|
|
const eligibleCandidatesBeforeCompatibility = listEligibleUnknownCandidates(
|
|
updatedSituationGraph,
|
|
resolvedCurrentTurnNodeIds,
|
|
);
|
|
const eligibleCandidates = eligibleCandidatesBeforeCompatibility.filter(
|
|
(node) =>
|
|
assessReasoningPatternCompatibility({
|
|
node,
|
|
graph: updatedSituationGraph,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
}).compatible,
|
|
);
|
|
|
|
const compatibilityDiagnostics = collectPatternCompatibilityDiagnostics({
|
|
graph: updatedSituationGraph,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
candidateNodeIds: eligibleCandidates.map((node) => node.id),
|
|
});
|
|
|
|
const atomicityAssessment = decompositionResult.atomicityAssessment;
|
|
const answerabilityAssessment = decompositionResult.answerabilityAssessment;
|
|
const decompositionDepth = decompositionResult.decompositionDepth;
|
|
const decompositionAttempted = decompositionResult.decompositionAttempted;
|
|
const decompositionAccepted = decompositionResult.decompositionAccepted;
|
|
const decompositionStoppedReason =
|
|
decompositionResult.decompositionStoppedReason;
|
|
const proposedChildCount = decompositionResult.proposedChildCount;
|
|
const acceptedChildCount = decompositionResult.acceptedChildCount;
|
|
const rejectedChildren = decompositionResult.rejectedChildren;
|
|
const childQualitySummary = decompositionResult.childQualitySummary;
|
|
const selectedChildNodeId = decompositionResult.selectedChildNodeId;
|
|
const decompositionPerformed =
|
|
decompositionAttempted && decompositionAccepted;
|
|
const decompositionChildNodeIds = [
|
|
...new Set(
|
|
decompositionResult.proposalSnapshot.addedNodes
|
|
.filter((node) => node.kind === "unknown" && node.parentId != null)
|
|
.map((node) => node.id),
|
|
),
|
|
];
|
|
const decompositionReason = decompositionStoppedReason;
|
|
const propagationPerformed = propagationResult.propagationPerformed;
|
|
const resolvedChildNodeId = propagationResult.resolvedChildNodeId;
|
|
const parentNodeId = propagationResult.parentNodeId;
|
|
const parentStatusBefore = propagationResult.parentStatusBefore;
|
|
const parentStatusAfter = propagationResult.parentStatusAfter;
|
|
const parentConfidenceBefore = propagationResult.parentConfidenceBefore;
|
|
const parentConfidenceAfter = propagationResult.parentConfidenceAfter;
|
|
const evidenceConfidenceBefore = propagationResult.evidenceConfidenceBefore;
|
|
const evidenceConfidenceAfter = propagationResult.evidenceConfidenceAfter;
|
|
const completenessBefore = propagationResult.completenessBefore;
|
|
const completenessAfter = propagationResult.completenessAfter;
|
|
const conclusionConfidenceBefore =
|
|
propagationResult.conclusionConfidenceBefore;
|
|
const conclusionConfidenceAfter = propagationResult.conclusionConfidenceAfter;
|
|
const resolvedDirectChildren = propagationResult.resolvedDirectChildren;
|
|
const unresolvedDirectChildren = propagationResult.unresolvedDirectChildren;
|
|
const contradictoryDirectChildren =
|
|
propagationResult.contradictoryDirectChildren;
|
|
const confidenceCapReason = propagationResult.confidenceCapReason;
|
|
const ancestorPropagationStoppedReason =
|
|
propagationResult.ancestorPropagationStoppedReason;
|
|
const affectedAncestorIds = propagationResult.affectedAncestorIds;
|
|
const nextSelectedSibling = propagationResult.nextSelectedSibling;
|
|
const parentResolved = propagationResult.parentResolved;
|
|
const corroboratingBranchCount = propagationResult.corroboratingBranchCount;
|
|
const conflictingBranchCount = propagationResult.conflictingBranchCount;
|
|
const duplicateEvidenceCount = propagationResult.duplicateEvidenceCount;
|
|
const independentBranchCount = propagationResult.independentBranchCount;
|
|
const interactionSummary = propagationResult.interactionSummary;
|
|
const propagationReason = propagationResult.reason;
|
|
|
|
if (
|
|
deterministicSelection?.status === "selected" &&
|
|
deterministicSelection?.nodeId
|
|
) {
|
|
newActiveUnknownNodeId = deterministicSelection.nodeId;
|
|
} else if (deterministicSelection?.status === "ambiguous") {
|
|
newActiveUnknownNodeId = null;
|
|
} else if (eligibleCandidates.length > 0) {
|
|
const siblingFallbackNodeId =
|
|
nextSelectedSibling || eligibleCandidates[0]?.id || null;
|
|
if (siblingFallbackNodeId) {
|
|
deterministicSelection = {
|
|
status: "selected",
|
|
nodeId: siblingFallbackNodeId,
|
|
reason:
|
|
"Selected an eligible sibling after update-time validation left the original follow-up unavailable.",
|
|
};
|
|
newActiveUnknownNodeId = siblingFallbackNodeId;
|
|
} else {
|
|
newActiveUnknownNodeId = null;
|
|
}
|
|
} else {
|
|
newActiveUnknownNodeId = null;
|
|
}
|
|
|
|
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
|
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
|
|
|
const selectedNode =
|
|
deterministicSelection?.status === "selected" &&
|
|
deterministicSelection?.nodeId
|
|
? updatedSituationGraph.nodes.find(
|
|
(node) => node.id === deterministicSelection.nodeId,
|
|
)
|
|
: null;
|
|
const formulatedQuestion = selectedNode
|
|
? formulateQuestion({
|
|
node: selectedNode,
|
|
graph: updatedSituationGraph,
|
|
context: {
|
|
resolvedValues: validatedProposal.updatedNodes
|
|
.map((update) => update.newValue)
|
|
.filter(
|
|
(value) => typeof value === "string" && value.trim().length > 0,
|
|
),
|
|
selectionState: deterministicSelection,
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const questionComplexity = formulatedQuestion?.questionComplexity ?? null;
|
|
const plainLanguageNormalisations =
|
|
formulatedQuestion?.plainLanguageNormalisations ?? [];
|
|
|
|
const finalSelectedQuestion =
|
|
deterministicSelection?.status === "ambiguous"
|
|
? {
|
|
nodeId: null,
|
|
tiedCandidateIds: deterministicSelection.tiedCandidateIds,
|
|
question: null,
|
|
reason: deterministicSelection.reason,
|
|
}
|
|
: deterministicSelection?.status === "selected"
|
|
? {
|
|
nodeId: deterministicSelection.nodeId,
|
|
question:
|
|
formulatedQuestion?.question || deterministicSelection.question,
|
|
reason: formulatedQuestion?.reason || deterministicSelection.reason,
|
|
strategy: formulatedQuestion?.strategy,
|
|
investigationStrategy: formulatedQuestion?.investigationStrategy,
|
|
reasoningPattern: formulatedQuestion?.reasoningPattern,
|
|
reasoningPatternReason: formulatedQuestion?.reasoningPatternReason,
|
|
questionFamily: formulatedQuestion?.questionFamily,
|
|
allowedQuestionFamilies:
|
|
formulatedQuestion?.allowedQuestionFamilies,
|
|
rejectedQuestionFamilies:
|
|
formulatedQuestion?.rejectedQuestionFamilies,
|
|
selectedQuestionTemplate:
|
|
formulatedQuestion?.selectedQuestionTemplate,
|
|
questionComplexity,
|
|
plainLanguageNormalisations,
|
|
}
|
|
: null;
|
|
|
|
const repeatedQuestionRejected = Boolean(
|
|
finalSelectedQuestion?.question &&
|
|
deterministicSelection?.status === "selected" &&
|
|
isStructurallyRepeatedQuestion({
|
|
previousQuestion,
|
|
previousNode: previousActiveUnknownNode,
|
|
nextQuestion: finalSelectedQuestion.question,
|
|
nextNode: selectedNode,
|
|
nextQuestionFamily: finalSelectedQuestion.questionFamily,
|
|
nextInvestigationStrategy: finalSelectedQuestion.strategy,
|
|
}),
|
|
);
|
|
|
|
if (repeatedQuestionRejected) {
|
|
const repeatedSelection = reseatSelectionAfterQuestionRejection({
|
|
graph: updatedSituationGraph,
|
|
deterministicSelection,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
excludedNodeIds: [deterministicSelection.nodeId],
|
|
});
|
|
|
|
if (
|
|
repeatedSelection?.status === "selected" &&
|
|
repeatedSelection.nodeId !== deterministicSelection.nodeId
|
|
) {
|
|
deterministicSelection = repeatedSelection;
|
|
}
|
|
}
|
|
|
|
const selectedNodeAfterRepetitionCheck =
|
|
deterministicSelection?.status === "selected" &&
|
|
deterministicSelection?.nodeId
|
|
? updatedSituationGraph.nodes.find(
|
|
(node) => node.id === deterministicSelection.nodeId,
|
|
)
|
|
: null;
|
|
const formulatedQuestionAfterRepetitionCheck =
|
|
selectedNodeAfterRepetitionCheck
|
|
? formulateQuestion({
|
|
node: selectedNodeAfterRepetitionCheck,
|
|
graph: updatedSituationGraph,
|
|
context: {
|
|
resolvedValues: validatedProposal.updatedNodes
|
|
.map((update) => update.newValue)
|
|
.filter(
|
|
(value) => typeof value === "string" && value.trim().length > 0,
|
|
),
|
|
selectionState: deterministicSelection,
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const effectiveFormulatedQuestion =
|
|
formulatedQuestionAfterRepetitionCheck || formulatedQuestion;
|
|
const effectiveSelectedNode =
|
|
selectedNodeAfterRepetitionCheck || selectedNode;
|
|
const effectiveQuestionComplexity =
|
|
effectiveFormulatedQuestion?.questionComplexity ?? null;
|
|
const effectivePlainLanguageNormalisations =
|
|
effectiveFormulatedQuestion?.plainLanguageNormalisations ?? [];
|
|
|
|
const effectiveSelectedQuestion =
|
|
deterministicSelection?.status === "ambiguous"
|
|
? {
|
|
nodeId: null,
|
|
tiedCandidateIds: deterministicSelection.tiedCandidateIds,
|
|
question: null,
|
|
reason: deterministicSelection.reason,
|
|
}
|
|
: deterministicSelection?.status === "selected"
|
|
? {
|
|
nodeId: deterministicSelection.nodeId,
|
|
question:
|
|
effectiveFormulatedQuestion?.question ||
|
|
deterministicSelection.question,
|
|
reason:
|
|
repeatedQuestionRejected &&
|
|
deterministicSelection?.nodeId !== previousActiveUnknownNodeId
|
|
? buildRepeatedQuestionDiagnostics(effectiveSelectedNode)
|
|
: effectiveFormulatedQuestion?.reason ||
|
|
deterministicSelection.reason,
|
|
strategy: effectiveFormulatedQuestion?.strategy,
|
|
investigationStrategy:
|
|
effectiveFormulatedQuestion?.investigationStrategy,
|
|
reasoningPattern: effectiveFormulatedQuestion?.reasoningPattern,
|
|
reasoningPatternReason:
|
|
effectiveFormulatedQuestion?.reasoningPatternReason,
|
|
questionFamily: effectiveFormulatedQuestion?.questionFamily,
|
|
allowedQuestionFamilies:
|
|
effectiveFormulatedQuestion?.allowedQuestionFamilies,
|
|
rejectedQuestionFamilies:
|
|
effectiveFormulatedQuestion?.rejectedQuestionFamilies,
|
|
selectedQuestionTemplate:
|
|
effectiveFormulatedQuestion?.selectedQuestionTemplate,
|
|
questionComplexity: effectiveQuestionComplexity,
|
|
plainLanguageNormalisations: effectivePlainLanguageNormalisations,
|
|
}
|
|
: null;
|
|
|
|
const finalSelectionCompatibility = effectiveSelectedQuestion?.nodeId
|
|
? assessReasoningPatternCompatibility({
|
|
node: findNodeById(
|
|
updatedSituationGraph,
|
|
effectiveSelectedQuestion.nodeId,
|
|
),
|
|
graph: updatedSituationGraph,
|
|
activePattern: decompositionResult.activeReasoningPattern,
|
|
})
|
|
: null;
|
|
|
|
const finalSelectedChildNodeId =
|
|
selectedChildNodeId ??
|
|
(selectedQuestionBelongsToChild(
|
|
updatedSituationGraph,
|
|
effectiveSelectedQuestion,
|
|
)
|
|
? effectiveSelectedQuestion?.nodeId
|
|
: null);
|
|
const noQuestionReason = effectiveSelectedQuestion?.question
|
|
? null
|
|
: deterministicSelection?.status === "ambiguous"
|
|
? "Eligible unresolved candidates remain tied after update-time reselection."
|
|
: eligibleCandidates.length === 0
|
|
? unresolvedCandidates.length === 0
|
|
? "No unresolved unknown candidates remain after this update."
|
|
: "Unresolved unknowns remain, but none are currently eligible for direct investigation."
|
|
: "Question formulation did not produce a valid next question despite eligible unresolved candidates.";
|
|
|
|
const resultGraphValidation = situationGraphSchema.safeParse(
|
|
updatedSituationGraph,
|
|
);
|
|
const resultReferenceValidation = resultGraphValidation.success
|
|
? validateGraphReferences(updatedSituationGraph)
|
|
: null;
|
|
const resultDuplicateNodeIds = resultGraphValidation.success
|
|
? detectDuplicateNodeIds(updatedSituationGraph.nodes)
|
|
: [];
|
|
const resultDuplicateEdgeIds = resultGraphValidation.success
|
|
? collectDuplicateEdgeIds(updatedSituationGraph.edges)
|
|
: [];
|
|
|
|
if (
|
|
!resultGraphValidation.success ||
|
|
!resultReferenceValidation?.valid ||
|
|
resultDuplicateNodeIds.length > 0 ||
|
|
resultDuplicateEdgeIds.length > 0 ||
|
|
(effectiveSelectedQuestion?.nodeId &&
|
|
!finalSelectionCompatibility?.compatible)
|
|
) {
|
|
return {
|
|
success: false,
|
|
stage: "result_validation",
|
|
errors: [
|
|
...(!resultGraphValidation.success
|
|
? zodIssuesToErrors(resultGraphValidation.error)
|
|
: []),
|
|
...(!resultReferenceValidation?.valid
|
|
? resultReferenceValidation.errors
|
|
: []),
|
|
...resultDuplicateNodeIds.map(
|
|
({ nodeId, count }) =>
|
|
`Updated graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
|
),
|
|
...resultDuplicateEdgeIds.map(
|
|
({ edgeId, count }) =>
|
|
`Updated graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
|
|
),
|
|
...(!finalSelectionCompatibility?.compatible &&
|
|
effectiveSelectedQuestion?.nodeId
|
|
? [
|
|
`Active unknown violates reasoning pattern consistency: "${effectiveSelectedQuestion.nodeId}" is ${finalSelectionCompatibility?.nodePattern} but active pattern is ${finalSelectionCompatibility?.activePattern}`,
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
updatedSituationGraph,
|
|
graphUpdate: proposalSnapshot,
|
|
affectedNodeIds,
|
|
resolvedUnknownNodeIds: proposalSnapshot.resolvedUnknownNodeIds,
|
|
resolvedReasoningNodeIds: reasoningResolution.resolvedReasoningNodeIds,
|
|
emergentReasoningNodeCreated: Boolean(emergentReasoningUnknown?.created),
|
|
emergentReasoningNodeId: emergentReasoningUnknown?.node?.id ?? null,
|
|
emergentReasoningNodeReason: emergentReasoningUnknown?.reason ?? null,
|
|
atomicityAssessment: atomicityAssessment?.atomicity ?? null,
|
|
atomicityDecisionReason: atomicityAssessment?.reason ?? null,
|
|
answerabilityAssessment,
|
|
independentlyAnswerable:
|
|
answerabilityAssessment?.independentlyAnswerable ?? null,
|
|
prerequisiteConceptCount:
|
|
answerabilityAssessment?.prerequisiteConceptCount ?? null,
|
|
decompositionDepth,
|
|
decompositionAttempted,
|
|
decompositionAccepted,
|
|
decompositionStoppedReason,
|
|
proposedChildCount,
|
|
acceptedChildCount,
|
|
rejectedChildren,
|
|
selectedChildNodeId: finalSelectedChildNodeId,
|
|
childQualitySummary,
|
|
selectedUnknownBefore: decompositionResult.selectedUnknownBefore,
|
|
selectedUnknownAfter: deterministicSelection?.nodeId ?? null,
|
|
questionComplexityAccepted: effectiveQuestionComplexity?.acceptable ?? null,
|
|
primaryConceptCount:
|
|
effectiveQuestionComplexity?.primaryConceptCount ?? null,
|
|
cognitiveLoad: effectiveQuestionComplexity?.cognitiveLoad ?? null,
|
|
complexityReasons: effectiveQuestionComplexity?.reasons ?? [],
|
|
decompositionTriggeredByQuestionComplexity:
|
|
decompositionResult.decompositionTriggeredByQuestionComplexity ?? false,
|
|
decompositionTriggeredByAnswerability:
|
|
decompositionResult.decompositionTriggeredByAnswerability ?? false,
|
|
previousQuestion,
|
|
finalQuestion: effectiveSelectedQuestion?.question ?? null,
|
|
unresolvedCandidateCount: unresolvedCandidates.length,
|
|
eligibleCandidateCount: eligibleCandidates.length,
|
|
candidateNodeIds: eligibleCandidates.map((node) => node.id),
|
|
resolvedCurrentTurnNodeIds,
|
|
noQuestionReason,
|
|
plainLanguageNormalisations: effectivePlainLanguageNormalisations,
|
|
propagationPerformed,
|
|
resolvedChildNodeId,
|
|
parentNodeId,
|
|
parentStatusBefore,
|
|
parentStatusAfter,
|
|
parentConfidenceBefore,
|
|
parentConfidenceAfter,
|
|
evidenceConfidenceBefore,
|
|
evidenceConfidenceAfter,
|
|
completenessBefore,
|
|
completenessAfter,
|
|
conclusionConfidenceBefore,
|
|
conclusionConfidenceAfter,
|
|
resolvedDirectChildren,
|
|
unresolvedDirectChildren,
|
|
contradictoryDirectChildren,
|
|
corroboratingBranchCount,
|
|
conflictingBranchCount,
|
|
duplicateEvidenceCount,
|
|
independentBranchCount,
|
|
interactionSummary,
|
|
confidenceCapReason,
|
|
ancestorPropagationStoppedReason,
|
|
affectedAncestorIds,
|
|
nextSelectedSibling,
|
|
parentResolved,
|
|
decompositionPerformed,
|
|
childUnknownCount: decompositionChildNodeIds.length,
|
|
childNodeIds: decompositionChildNodeIds,
|
|
selectedContainerUnknown:
|
|
decompositionResult.selectedContainerUnknown ?? null,
|
|
reasoningPatternValidation:
|
|
compatibilityDiagnostics.reasoningPatternValidation,
|
|
patternCompatibleNodeCount:
|
|
compatibilityDiagnostics.patternCompatibleNodeCount,
|
|
incompatibleNodeIds: [
|
|
...new Set([
|
|
...(decompositionResult.incompatibleNodeIds || []),
|
|
...postPropagationIncompatibleNodeIds,
|
|
...activeUnknownIncompatibleNodeIds,
|
|
...compatibilityDiagnostics.incompatibleNodeIds,
|
|
]),
|
|
],
|
|
compatibilityFailures: [
|
|
...(decompositionResult.compatibilityFailures || []),
|
|
...postPropagationCompatibilityFailures,
|
|
...activeUnknownCompatibilityFailures,
|
|
...compatibilityDiagnostics.compatibilityFailures,
|
|
],
|
|
replacementActions: [
|
|
...(decompositionResult.replacementActions || []),
|
|
...postPropagationReplacementActions,
|
|
...activeUnknownReplacementActions,
|
|
],
|
|
graphReasoningIntegrity: compatibilityDiagnostics.graphReasoningIntegrity,
|
|
selectedChildUnknown:
|
|
finalSelectedChildNodeId ??
|
|
(deterministicSelection?.status === "selected"
|
|
? deterministicSelection.nodeId
|
|
: null),
|
|
atomicityReason:
|
|
propagationReason ||
|
|
decompositionReason ||
|
|
atomicityAssessment?.reason ||
|
|
null,
|
|
previousActiveUnknownNodeId,
|
|
newActiveUnknownNodeId,
|
|
selectedQuestion: effectiveSelectedQuestion,
|
|
changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds),
|
|
graphReferenceValidation: resultReferenceValidation,
|
|
previousReasoningState: reasoningResolution.previousReasoningState,
|
|
reasoningState: nextReasoningState,
|
|
};
|
|
}
|