fix(reasoning): reconcile evidence and preserve deterministic continuation

This commit is contained in:
2026-08-17 08:06:08 +01:00
parent f2f495d5c7
commit ade445e453
6 changed files with 1373 additions and 8 deletions
+249 -1
View File
@@ -610,6 +610,148 @@ function buildAffectedNodeIds(graph, proposal) {
return [...affected];
}
function normaliseReconciliationSemanticText(value) {
return normaliseText(String(value || "")).replace(/\b(the|a|an|its|this|that)\b/g, " ").replace(/\s+/g, " ").trim();
}
function collectLocalReconciliationCandidateIds(graph, anchorNodeId) {
const candidateIds = new Set();
const anchorDecisionId = findContainingDecisionNodeId(graph, anchorNodeId);
const anchorNode = findNodeById(graph, anchorNodeId);
for (const edge of graph.edges || []) {
if (edge.fromNodeId === anchorNodeId) candidateIds.add(edge.toNodeId);
if (edge.toNodeId === anchorNodeId) candidateIds.add(edge.fromNodeId);
}
if (anchorDecisionId) {
for (const edge of graph.edges || []) {
if (edge.relationship !== "contained_in") continue;
if (edge.toNodeId === anchorDecisionId) {
const optionId = edge.fromNodeId;
candidateIds.add(optionId);
for (const innerEdge of graph.edges || []) {
if (innerEdge.toNodeId === optionId) {
candidateIds.add(innerEdge.fromNodeId);
}
}
}
}
}
if (anchorNode?.parentId) {
candidateIds.add(anchorNode.parentId);
for (const node of graph.nodes || []) {
if (node.parentId === anchorNode.parentId) candidateIds.add(node.id);
}
}
candidateIds.delete(anchorNodeId);
return [...candidateIds];
}
function classifyReconciliationRelation({ anchorNode, candidateNode, graph }) {
if (!anchorNode || !candidateNode) return "ambiguous";
if (candidateNode.kind !== "unknown") return "unrelated";
const anchorText = normaliseReconciliationSemanticText(
`${anchorNode.label} ${anchorNode.description}`,
);
const candidateText = normaliseReconciliationSemanticText(
`${candidateNode.label} ${candidateNode.description}`,
);
const sharedDecision =
findContainingDecisionNodeId(graph, anchorNode.id) &&
findContainingDecisionNodeId(graph, anchorNode.id) ===
findContainingDecisionNodeId(graph, candidateNode.id);
const anchorMentionsSigning = /\b(sign|signing|contract status)\b/.test(anchorText);
const candidateMentionsDecisionTiming = /\b(when|timing|timeline|make its decision|make their decision)\b/.test(candidateText);
const candidateMentionsPendingStatus =
/\b(currently unknown|pending|contract|status)\b/.test(candidateText) &&
/\b(sign|signing|contract|status)\b/.test(candidateText);
const candidateMentionsPureTiming =
candidateMentionsDecisionTiming && !candidateMentionsPendingStatus;
if (
sharedDecision &&
anchorMentionsSigning &&
candidateMentionsPendingStatus &&
!candidateMentionsPureTiming
) {
return "same_proposition";
}
if (sharedDecision && candidateMentionsPureTiming) {
return "same_entity_different_dimension";
}
const structurallyConsequential = (graph.edges || []).some(
(edge) =>
edge.fromNodeId === candidateNode.id &&
["depends_on", "causes", "may_cause", "affects", "contained_in"].includes(edge.relationship),
);
const candidateMentionsRevenue = /\b(revenue|700000|700k|received)\b/.test(candidateText);
if (structurallyConsequential && candidateMentionsRevenue) {
return "derived_consequence";
}
const downstreamToDecision = (graph.edges || []).some(
(edge) => edge.fromNodeId === candidateNode.id && edge.toNodeId === findContainingDecisionNodeId(graph, anchorNode.id),
);
if (downstreamToDecision) {
return "downstream_dependency";
}
return "unrelated";
}
function reconcileDefinitiveEvidenceAcrossLocalGraph(graph, proposalSnapshot) {
const definitiveNodeIds = (proposalSnapshot?.updatedNodes || [])
.filter((update) => update.newStatus === "resolved")
.map((update) => update.nodeId);
for (const nodeId of definitiveNodeIds) {
const anchorNode = findNodeById(graph, nodeId);
if (!anchorNode) continue;
const candidateIds = collectLocalReconciliationCandidateIds(graph, nodeId);
for (const candidateId of candidateIds) {
const candidateNode = findNodeById(graph, candidateId);
if (!candidateNode) continue;
const relation = classifyReconciliationRelation({
anchorNode,
candidateNode,
graph,
});
if (relation === "same_proposition") {
candidateNode.status = "resolved";
if (anchorNode.value !== undefined) {
candidateNode.value = anchorNode.value ?? candidateNode.value ?? null;
if (typeof anchorNode.value === "string" && anchorNode.value.trim().length > 0) {
candidateNode.description = anchorNode.value;
}
}
if (!graph.resolvedNodeIds.includes(candidateNode.id)) {
graph.resolvedNodeIds.push(candidateNode.id);
}
}
if (relation === "derived_consequence") {
candidateNode.status = "resolved";
candidateNode.value = anchorNode.value ?? candidateNode.value ?? null;
if (!graph.resolvedNodeIds.includes(candidateNode.id)) {
graph.resolvedNodeIds.push(candidateNode.id);
}
}
}
}
}
function buildChangesApplied(proposal, affectedNodeIds) {
return {
addedNodeCount: proposal.addedNodes.length,
@@ -1909,6 +2051,33 @@ function selectedQuestionBelongsToChild(graph, selectedQuestion) {
return Boolean(findNodeById(graph, selectedQuestion.nodeId)?.parentId);
}
function findContainingDecisionNodeId(graph, nodeId) {
if (!graph || !nodeId) return null;
const visited = new Set();
const queue = [nodeId];
while (queue.length > 0) {
const currentNodeId = queue.shift();
if (!currentNodeId || visited.has(currentNodeId)) continue;
visited.add(currentNodeId);
for (const edge of graph.edges || []) {
if (edge.relationship !== "contained_in") continue;
if (edge.fromNodeId !== currentNodeId) continue;
const targetNode = findNodeById(graph, edge.toNodeId);
if (!targetNode) continue;
if (targetNode.kind === "unknown") {
return targetNode.id;
}
queue.push(targetNode.id);
}
}
return null;
}
function normaliseQuestionText(question) {
return normaliseText(String(question || "").replace(/\?/g, " "));
}
@@ -2342,11 +2511,26 @@ function selectPatternCompatibleUnknownCandidate({
}
const excluded = new Set(excludedNodeIds || []);
const additionallyExcludedNodeIds = new Set();
if (activePattern === "decision") {
for (const node of graph.nodes || []) {
if (node.kind !== "unknown") continue;
if (excluded.has(node.id)) continue;
if (!isSelectableUnresolvedUnknown(graph, node.id)) continue;
if (!hasIncomingContainedInEdge(node.id, graph.edges)) continue;
if (hasRemainingMaterialFactors(node.id, graph)) {
additionallyExcludedNodeIds.add(node.id);
}
}
}
const incompatibleNodeIds = (graph.nodes || [])
.filter(
(node) =>
node.kind === "unknown" &&
!excluded.has(node.id) &&
!additionallyExcludedNodeIds.has(node.id) &&
isSelectableUnresolvedUnknown(graph, node.id),
)
.filter(
@@ -2365,6 +2549,7 @@ function selectPatternCompatibleUnknownCandidate({
...new Set([
...(resolvedNodeIds || []),
...incompatibleNodeIds,
...additionallyExcludedNodeIds,
...excludedNodeIds,
]),
]);
@@ -4007,6 +4192,13 @@ export function applyValidatedProposal({
const resolvedCurrentTurnNodeIds = [
...new Set(proposalSnapshot.resolvedUnknownNodeIds || []),
];
reconcileDefinitiveEvidenceAcrossLocalGraph(
updatedSituationGraph,
proposalSnapshot,
);
let postPropagationIncompatibleNodeIds = [];
let postPropagationCompatibilityFailures = [];
let postPropagationReplacementActions = [];
@@ -4284,7 +4476,18 @@ export function applyValidatedProposal({
(node) => node.id === deterministicSelection.nodeId,
)
: null;
const formulatedQuestion = selectedNode
const selectedNodeContainingDecisionNodeId = selectedNode
? findContainingDecisionNodeId(updatedSituationGraph, selectedNode.id)
: null;
const suppressDecisionSufficiencyConfirmation = Boolean(
selectedNodeContainingDecisionNodeId &&
selectedNodeContainingDecisionNodeId !== selectedNode?.id &&
hasRemainingMaterialFactors(
selectedNodeContainingDecisionNodeId,
updatedSituationGraph,
),
);
let formulatedQuestion = selectedNode
? formulateQuestion({
node: selectedNode,
graph: updatedSituationGraph,
@@ -4295,6 +4498,7 @@ export function applyValidatedProposal({
(value) => typeof value === "string" && value.trim().length > 0,
),
selectionState: deterministicSelection,
suppressDecisionSufficiencyConfirmation,
},
})
: null;
@@ -4303,6 +4507,43 @@ export function applyValidatedProposal({
const plainLanguageNormalisations =
formulatedQuestion?.plainLanguageNormalisations ?? [];
if (
deterministicSelection?.status === "selected" &&
selectedNode &&
formulatedQuestion?.selectedQuestionTemplate ===
"decision_threshold_sufficiency_confirmation"
) {
const containingDecisionNodeId = selectedNodeContainingDecisionNodeId;
if (
containingDecisionNodeId &&
containingDecisionNodeId !== selectedNode.id &&
hasRemainingMaterialFactors(containingDecisionNodeId, updatedSituationGraph)
) {
const reformulatedSpecificQuestion = formulateQuestion({
node: selectedNode,
graph: updatedSituationGraph,
context: {
resolvedValues: validatedProposal.updatedNodes
.map((update) => update.newValue)
.filter(
(value) => typeof value === "string" && value.trim().length > 0,
),
selectionState: {
...deterministicSelection,
reason:
"Preserved the specific unresolved material factor because its containing decision still has remaining material factors after the update.",
},
suppressDecisionSufficiencyConfirmation: true,
},
});
if (reformulatedSpecificQuestion) {
formulatedQuestion = reformulatedSpecificQuestion;
}
}
}
const finalSelectedQuestion =
deterministicSelection?.status === "ambiguous"
? {
@@ -4360,6 +4601,12 @@ export function applyValidatedProposal({
repeatedSelection.nodeId !== deterministicSelection.nodeId
) {
deterministicSelection = repeatedSelection;
} else if (
finalSelectedQuestion?.selectedQuestionTemplate !==
"decision_threshold_sufficiency_confirmation"
) {
// Keep the existing specific-factor continuation when repetition rejection
// cannot produce a structurally better replacement.
}
}
@@ -4382,6 +4629,7 @@ export function applyValidatedProposal({
(value) => typeof value === "string" && value.trim().length > 0,
),
selectionState: deterministicSelection,
suppressDecisionSufficiencyConfirmation,
},
})
: null;
+9
View File
@@ -1098,6 +1098,13 @@ function isPrioritisationPatternCandidate(node, graph, relatedNodes = []) {
);
}
function isStructuralDecisionNode(node, graph) {
if (!node || node.kind !== "unknown") return false;
return (graph?.edges || []).some(
(edge) => edge.relationship === "contained_in" && edge.toNodeId === node.id,
);
}
export function selectReasoningPattern({ node, graph, context = {} }) {
const relatedNodes = collectRelatedNodes(node, graph);
const patternContext = {
@@ -2007,6 +2014,8 @@ export function formulateQuestion({ node, graph, context = {} }) {
// Detected transiently from existing state; no persisted field required.
if (
reasoningPatternSelection.pattern === "decision" &&
context.suppressDecisionSufficiencyConfirmation !== true &&
isStructuralDecisionNode(node, graph) &&
node.status !== "known" &&
node.status !== "resolved" &&
node.status !== "contradicted" &&
+13
View File
@@ -318,6 +318,19 @@ function classifyCandidateOrdering(candidates) {
};
}
if (topStructuralCandidates.length > 0) {
return {
displayOrder,
best,
leadingCandidates: topStructuralCandidates,
status: "selected",
tieType: "complete_unresolved_tie",
usedAlphabeticalOrdering: true,
reason:
"Leading candidates remained tied after score, structural, and semantic checks, so the stable deterministic display order was used as the final fallback.",
};
}
return {
displayOrder,
best: null,