fix(reasoning): reconcile evidence and preserve deterministic continuation
This commit is contained in:
+249
-1
@@ -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;
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user