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