From d871a8c5c462fb7c0e97196486c58fd37eb87bb4 Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 13 Aug 2026 12:49:42 +0100 Subject: [PATCH] fix(reasoning): scope structural context admission --- lib/graph/apply-proposal.js | 262 ++++++- .../reasoning-context-compatibility.test.js | 682 ++++++++++++++++++ 2 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 tests/graph/reasoning-context-compatibility.test.js diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index cff3490..5baf149 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -1805,6 +1805,7 @@ function determineActiveReasoningPattern(node, graph) { if (!node || !graph) { return { pattern: null, + sourceNodeId: null, reason: "No active reasoning pattern could be determined.", }; } @@ -1818,6 +1819,7 @@ function determineActiveReasoningPattern(node, 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}`, }; } @@ -1827,6 +1829,7 @@ function determineActiveReasoningPattern(node, graph) { const selection = selectReasoningPattern({ node, graph }); return { pattern: selection.pattern, + sourceNodeId: node.id, reason: selection.reason, }; } @@ -1880,7 +1883,101 @@ function inferIntrinsicNodePattern(node, graph) { return selectReasoningPattern({ node, graph }).pattern; } -function assessReasoningPatternCompatibility({ node, graph, activePattern }) { +// ── 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, @@ -1895,18 +1992,139 @@ function assessReasoningPatternCompatibility({ node, graph, activePattern }) { 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( @@ -1930,11 +2148,14 @@ function buildRejectedSelectionDiagnostics({ graph, activePattern, reason, + structurallyAdmittedNodeIds, }) { const compatibility = assessReasoningPatternCompatibility({ node, graph, activePattern, + activeNodeId: graph.activeUnknownNodeId ?? null, + structurallyAdmittedNodeIds, }); return { @@ -1950,6 +2171,7 @@ function selectPatternCompatibleUnknownCandidate({ resolvedNodeIds = [], activePattern, excludedNodeIds = [], + structurallyAdmittedNodeIds, }) { if (!activePattern) { return selectActiveUnknownCandidate(graph, resolvedNodeIds); @@ -1969,6 +2191,8 @@ function selectPatternCompatibleUnknownCandidate({ node, graph, activePattern, + activeNodeId: graph.activeUnknownNodeId ?? null, + structurallyAdmittedNodeIds, }).compatible, ) .map((node) => node.id); @@ -2021,6 +2245,7 @@ function collectPatternCompatibilityDiagnostics({ graph, activePattern, candidateNodeIds = [], + structurallyAdmittedNodeIds, }) { if (!activePattern) { return { @@ -2050,6 +2275,8 @@ function collectPatternCompatibilityDiagnostics({ node, graph, activePattern, + activeNodeId: graph.activeUnknownNodeId ?? null, + structurallyAdmittedNodeIds, }), })) .filter(({ compatibility }) => !compatibility.compatible) @@ -2079,6 +2306,7 @@ function selectDecompositionChildCandidate( graph, parentNodeId, activePattern = null, + structurallyAdmittedNodeIds, ) { const childCandidates = findDirectChildUnknowns(graph, parentNodeId) .filter( @@ -2097,6 +2325,8 @@ function selectDecompositionChildCandidate( node, graph, activePattern, + activeNodeId: graph.activeUnknownNodeId ?? null, + structurallyAdmittedNodeIds, }); return compatibility.compatible; }); @@ -2272,6 +2502,7 @@ function reseatSelectionAfterQuestionRejection({ deterministicSelection, activePattern = null, excludedNodeIds = [], + structurallyAdmittedNodeIds, }) { const nextSelection = activePattern ? selectPatternCompatibleUnknownCandidate({ @@ -2279,6 +2510,7 @@ function reseatSelectionAfterQuestionRejection({ resolvedNodeIds: graph.resolvedNodeIds || [], activePattern, excludedNodeIds, + structurallyAdmittedNodeIds, }) : selectActiveUnknownCandidate(graph, [ ...(graph.resolvedNodeIds || []), @@ -2439,6 +2671,7 @@ function runDeterministicDecomposition({ updatedSituationGraph, reasoningResolution, deterministicSelection, + structurallyAdmittedNodeIds = new Set(), }) { let workingGraph = updatedSituationGraph; let workingSelection = deterministicSelection; @@ -2466,6 +2699,7 @@ function runDeterministicDecomposition({ let selectedContainerUnknown = null; let activeReasoningPattern = null; let activeReasoningPatternReason = null; + let activeReasoningContextNodeId = null; let incompatibleNodeIds = []; let compatibilityFailures = []; let replacementActions = []; @@ -2485,12 +2719,15 @@ function runDeterministicDecomposition({ ); 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( @@ -2509,6 +2746,7 @@ function runDeterministicDecomposition({ resolvedNodeIds: workingGraph.resolvedNodeIds, activePattern: activeReasoningPattern, excludedNodeIds: [selectedNode.id], + structurallyAdmittedNodeIds, }); if (replacementSelection?.status === "selected") { replacementActions.push({ @@ -2570,6 +2808,7 @@ function runDeterministicDecomposition({ workingGraph, selectedNode.id, activeReasoningPattern, + structurallyAdmittedNodeIds, ); if (childSelection.status === "selected") { workingSelection = childSelection; @@ -2654,6 +2893,7 @@ function runDeterministicDecomposition({ workingGraph, selectedNode.id, activeReasoningPattern, + structurallyAdmittedNodeIds, ); if (workingSelection?.status !== "selected") { @@ -2707,6 +2947,7 @@ function runDeterministicDecomposition({ selectedContainerUnknown, activeReasoningPattern, activeReasoningPatternReason, + activeReasoningContextNodeId, incompatibleNodeIds, compatibilityFailures, replacementActions, @@ -3376,6 +3617,13 @@ export function applyValidatedProposal({ }; } + const structurallyAdmittedNodeIds = collectStructurallyAdmittedUnknownNodeIds( + { + graph: situationGraph, + proposal: validatedProposal, + }, + ); + const graphSnapshot = cloneJsonSafe(situationGraph); const proposalSnapshot = cloneJsonSafe(validatedProposal); const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null; @@ -3482,6 +3730,7 @@ export function applyValidatedProposal({ updatedSituationGraph, reasoningResolution, deterministicSelection, + structurallyAdmittedNodeIds, }); if (!decompositionResult.success) { @@ -3526,6 +3775,8 @@ export function applyValidatedProposal({ node: preservedSelectedChildNode, graph: updatedSituationGraph, activePattern: decompositionResult.activeReasoningPattern, + activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null, + structurallyAdmittedNodeIds, }) : null; @@ -3547,6 +3798,7 @@ export function applyValidatedProposal({ activePattern: decompositionResult.activeReasoningPattern, reason: "Preserved decomposition child violated the active reasoning pattern after propagation.", + structurallyAdmittedNodeIds, }); postPropagationIncompatibleNodeIds = rejectionDiagnostics.incompatibleNodeIds; @@ -3561,6 +3813,7 @@ export function applyValidatedProposal({ excludedNodeIds: preservedSelectedChildNode ? [preservedSelectedChildNode.id] : [], + structurallyAdmittedNodeIds, }); if ( @@ -3608,6 +3861,8 @@ export function applyValidatedProposal({ node: carriedActiveUnknownNode, graph: updatedSituationGraph, activePattern: decompositionResult.activeReasoningPattern, + activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null, + structurallyAdmittedNodeIds, }); if (!carriedActiveCompatibility.compatible) { @@ -3650,6 +3905,8 @@ export function applyValidatedProposal({ node, graph: updatedSituationGraph, activePattern: decompositionResult.activeReasoningPattern, + activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null, + structurallyAdmittedNodeIds, }).compatible, ); @@ -3844,6 +4101,7 @@ export function applyValidatedProposal({ deterministicSelection, activePattern: decompositionResult.activeReasoningPattern, excludedNodeIds: [deterministicSelection.nodeId], + structurallyAdmittedNodeIds, }); if ( @@ -3932,6 +4190,8 @@ export function applyValidatedProposal({ ), graph: updatedSituationGraph, activePattern: decompositionResult.activeReasoningPattern, + activeNodeId: decompositionResult.activeReasoningContextNodeId ?? null, + structurallyAdmittedNodeIds, }) : null; diff --git a/tests/graph/reasoning-context-compatibility.test.js b/tests/graph/reasoning-context-compatibility.test.js new file mode 100644 index 0000000..ee1c7db --- /dev/null +++ b/tests/graph/reasoning-context-compatibility.test.js @@ -0,0 +1,682 @@ +import { describe, expect, it } from "vitest"; +import { + applyValidatedProposal, + assessReasoningPatternCompatibility, + assessStructuralContextAdmission, +} from "@/lib/graph/apply-proposal.js"; +import { selectReasoningPattern } from "@/lib/graph/question-formulator.js"; +import { makeGraph, makeNode } from "@/lib/graph/schema.js"; + +// ── Helpers ────────────────────────────────────────────── + +function buildDecisionContext() { + const decision = makeNode({ + id: "n_relocation_decision", + label: "Whether continuing development is commercially justified", + description: + "Need to know whether continuing development is commercially justified before committing resources.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + return makeGraph({ + centralStatement: + "Relocation decision context for structural embedding tests.", + nodes: [decision], + edges: [], + activeUnknownNodeId: decision.id, + resolvedNodeIds: [], + currentSummary: "Decision fixture", + }); +} + +function addOption(graph, optionId) { + const option = makeNode({ + id: optionId, + label: "Relocate to new office", + description: "Move operations to a new location.", + kind: "option", + status: "provisional", + }); + graph.nodes.push(option); + graph.edges.push({ + id: `${optionId}_contained_in`, + fromNodeId: optionId, + toNodeId: graph.activeUnknownNodeId, + relationship: "contained_in", + confidence: "high", + description: "Option belongs to this decision.", + }); +} + +function resolveActivePattern(graph) { + const activeNode = graph.nodes.find( + (n) => n.id === graph.activeUnknownNodeId, + ); + if (!activeNode) return null; + return selectReasoningPattern({ node: activeNode, graph }).pattern; +} + +function assessBoundedAdmission(graph, node, overrides = {}) { + return assessStructuralContextAdmission({ + node, + graph, + activePattern: overrides.activePattern ?? resolveActivePattern(graph), + activeNodeId: overrides.activeNodeId ?? graph.activeUnknownNodeId, + }); +} + +function buildApplyProposalDecisionFixture() { + const decision = makeNode({ + id: "n_active_decision", + label: "Whether relocating is commercially justified", + description: + "Need to evaluate whether relocating is commercially justified.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + return makeGraph({ + centralStatement: "Relocation follow-up fixture.", + nodes: [decision], + edges: [], + activeUnknownNodeId: decision.id, + resolvedNodeIds: [], + currentSummary: "Decision apply-proposal fixture", + }); +} + +// ── Tests ──────────────────────────────────────────────── + +describe("reasoning-context compatibility — bounded structural admission (60B.19)", () => { + it("Test 1 - Route A: newly-added decision unknown admitted via parent/ancestor chain", () => { + const decision = makeNode({ + id: "n_decision_a", + label: "Investment context A", + description: "A parent context for the option under review.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const parentOption = makeNode({ + id: "opt_parent_a", + label: "Parent option A", + description: "An option for the decision.", + kind: "option", + status: "provisional", + parentId: "n_decision_a", + }); + + const childUnknown = makeNode({ + id: "n_child_embedded", + label: "What practical issue is blocking progress", + description: + "Need to identify the specific blocking issue before continuing this line of work.", + kind: "unknown", + status: "unknown", + confidence: "medium", + parentId: "opt_parent_a", + }); + + const graph = makeGraph({ + centralStatement: "Market entry decision.", + nodes: [decision, parentOption], + edges: [], + activeUnknownNodeId: "n_decision_a", + resolvedNodeIds: [], + currentSummary: "Route A fixture", + }); + + // childUnknown is not yet in graph.nodes; add it so we can build the chain + const compat = assessBoundedAdmission( + { ...graph, nodes: [...graph.nodes, childUnknown] }, + childUnknown, + { activePattern: "decision", activeNodeId: "n_decision_a" }, + ); + + expect(compat.admitted).toBe(true); + expect(compat.structuralEmbedding).toBe(true); + expect(compat.routeA).toBe(true); + }); + + it("Test 2 - Route B may_cause admitted", () => { + const graph = buildDecisionContext(); + addOption(graph, "opt_relocate"); + + const newUnknown = makeNode({ + id: "n_client_retention_uncertainty", + label: "What practical risk affects this option", + description: + "Need to identify the concrete risk factor affecting this option.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + graph.nodes.push(newUnknown); + graph.edges.push({ + id: "n_client_retention_uncertainty_may_cause_opt_relocate", + fromNodeId: "n_client_retention_uncertainty", + toNodeId: "opt_relocate", + relationship: "may_cause", + confidence: "medium", + description: "May cause option consequence.", + }); + + const compat = assessBoundedAdmission(graph, newUnknown); + + expect(compat.admitted).toBe(true); + expect(compat.structuralEmbedding).toBe(true); + expect(compat.routeB).toBe(true); + }); + + it("Test 3 - Route B causes admitted", () => { + const graph = buildDecisionContext(); + addOption(graph, "opt_relocate"); + + // Add the node and edge into the graph for traversal. + const newUnknown = makeNode({ + id: "n_client_retention_uncertainty", + label: "What practical risk affects this option", + description: + "Need to identify the concrete risk factor affecting this option.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + graph.nodes.push(newUnknown); + graph.edges.push({ + id: "n_client_retention_uncertainty_causes_opt_relocate", + fromNodeId: "n_client_retention_uncertainty", + toNodeId: "opt_relocate", + relationship: "causes", + confidence: "medium", + description: "Causal link.", + }); + + const compat = assessBoundedAdmission(graph, newUnknown); + + expect(compat.admitted).toBe(true); + expect(compat.structuralEmbedding).toBe(true); + expect(compat.routeB).toBe(true); + }); + + it("Test 4 - Route B affects admitted", () => { + const graph = buildDecisionContext(); + addOption(graph, "opt_relocate"); + + const newUnknown = makeNode({ + id: "n_client_retention_uncertainty", + label: "What practical risk affects this option", + description: + "Need to identify the concrete risk factor affecting this option.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + graph.nodes.push(newUnknown); + graph.edges.push({ + id: "n_client_retention_uncertainty_affects_opt_relocate", + fromNodeId: "n_client_retention_uncertainty", + toNodeId: "opt_relocate", + relationship: "affects", + confidence: "medium", + description: "Impact link.", + }); + + const compat = assessBoundedAdmission(graph, newUnknown); + + expect(compat.admitted).toBe(true); + expect(compat.structuralEmbedding).toBe(true); + expect(compat.routeB).toBe(true); + }); + + it("Test 5 - supports does NOT qualify as structural embedding", () => { + const graph = buildDecisionContext(); + addOption(graph, "opt_relocate"); + + const newUnknown = makeNode({ + id: "n_supporting_factor", + label: "What causes the revenue discrepancy?", + description: "Need to understand root cause of divergence.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + graph.nodes.push(newUnknown); + graph.edges.push({ + id: "n_supporting_factor_supports_opt_relocate", + fromNodeId: "n_supporting_factor", + toNodeId: "opt_relocate", + relationship: "supports", + confidence: "medium", + description: "Support link.", + }); + + const compat = assessBoundedAdmission(graph, newUnknown); + + expect(compat.admitted).toBe(false); + expect(compat.structuralEmbedding).toBe(false); + }); + + it("Test 6 - measures does NOT qualify as structural embedding", () => { + const graph = buildDecisionContext(); + addOption(graph, "opt_relocate"); + + const newUnknown = makeNode({ + id: "n_measuring_node", + label: "What causes the revenue discrepancy?", + description: "Need to understand root cause of divergence.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + graph.nodes.push(newUnknown); + graph.edges.push({ + id: "n_measuring_node_measures_opt_relocate", + fromNodeId: "n_measuring_node", + toNodeId: "opt_relocate", + relationship: "measures", + confidence: "medium", + description: "Measurement link.", + }); + + const compat = assessBoundedAdmission(graph, newUnknown); + + expect(compat.admitted).toBe(false); + expect(compat.structuralEmbedding).toBe(false); + }); + + it("Test 7 - depends_on does NOT qualify as structural embedding", () => { + const graph = buildDecisionContext(); + addOption(graph, "opt_relocate"); + + const newUnknown = makeNode({ + id: "n_depends_node", + label: "What causes the revenue discrepancy?", + description: "Need to understand root cause of divergence.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + graph.nodes.push(newUnknown); + graph.edges.push({ + id: "n_depends_node_depends_on_opt_relocate", + fromNodeId: "n_depends_node", + toNodeId: "opt_relocate", + relationship: "depends_on", + confidence: "medium", + description: "Dependency link.", + }); + + const compat = assessBoundedAdmission(graph, newUnknown); + + expect(compat.admitted).toBe(false); + expect(compat.structuralEmbedding).toBe(false); + }); + + it("Test 8 - arbitrary graph connectivity does NOT produce compatibility", () => { + const graph = buildDecisionContext(); + addOption(graph, "opt_relocate"); + + const intermediateNode = makeNode({ + id: "n_intermediate", + label: "Some unrelated factor", + description: "Not relevant.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const newUnknown = makeNode({ + id: "n_arbitrary_path", + label: "What causes the revenue discrepancy?", + description: "Need to understand root cause of divergence.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + graph.nodes.push(intermediateNode, newUnknown); + graph.edges.push({ + id: "n_arbitrary_path_may_cause_intermediate", + fromNodeId: "n_arbitrary_path", + toNodeId: "n_intermediate", + relationship: "may_cause", + confidence: "medium", + description: "Connects to intermediate, not an option.", + }); + + const compat = assessBoundedAdmission(graph, newUnknown); + + expect(compat.admitted).toBe(false); + expect(compat.structuralEmbedding).toBe(false); + }); + + it("Test 9 - wrong decision context rejects", () => { + const otherDecision = makeNode({ + id: "n_other_decision", + label: "Should we launch product B?", + description: "Different decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const baseGraph = makeGraph({ + centralStatement: "Testing wrong decision context.", + nodes: [buildDecisionContext().nodes[0], otherDecision], + edges: [], + activeUnknownNodeId: "n_relocation_decision", + resolvedNodeIds: [], + currentSummary: "Wrong context fixture", + }); + + const wrongOption = makeNode({ + id: "opt_wrong_context", + label: "Launch product B option", + description: "Belongs to other decision.", + kind: "option", + status: "provisional", + }); + + baseGraph.nodes.push(wrongOption); + baseGraph.edges.push({ + id: "opt_wrong_context_contained_in_other", + fromNodeId: "opt_wrong_context", + toNodeId: "n_other_decision", + relationship: "contained_in", + confidence: "high", + description: "Wrong decision membership.", + }); + + const newUnknown = makeNode({ + id: "n_wrong_context_unknown", + label: "What practical risk affects this option", + description: + "Need to identify the concrete risk factor affecting this option.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + baseGraph.nodes.push(newUnknown); + baseGraph.edges.push({ + id: "n_wrong_context_unknown_may_cause_opt", + fromNodeId: "n_wrong_context_unknown", + toNodeId: "opt_wrong_context", + relationship: "may_cause", + confidence: "medium", + description: "May cause wrong option.", + }); + + const compat = assessBoundedAdmission(baseGraph, newUnknown); + + expect(compat.admitted).toBe(false); + expect(compat.structuralEmbedding).toBe(false); + }); + + it("Test 10 - already-compatible pattern remains unchanged", () => { + const decision = makeNode({ + id: "n_decision_10", + label: "Whether proceeding is commercially justified", + description: + "Need to evaluate whether proceeding is commercially justified.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const definitionNode = makeNode({ + id: "n_definition_node", + label: "Definition of commercially justified", + description: "Define what commercially justified means. Define the term.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraph({ + centralStatement: "Decision context.", + nodes: [decision, definitionNode], + edges: [], + activeUnknownNodeId: "n_decision_10", + resolvedNodeIds: [], + currentSummary: "Already compatible fixture", + }); + + const compat = assessBoundedAdmission(graph, definitionNode); + + expect(compat.intrinsicCompatible).toBe(true); + expect(compat.admitted).toBe(false); + expect(compat.structuralEmbedding).toBe(false); + }); + + it("Test 11 - genuine incompatible child under explanation is rejected", () => { + const explanationUnknown = makeNode({ + id: "n_explanation", + label: "Explanation for why revenue increased by 18%", + description: + "Need to understand what change or event could explain these observations.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraph({ + centralStatement: + "Revenue increased by 18%, but cash in the bank fell over the same period.", + nodes: [ + explanationUnknown, + makeNode({ + id: "n_revenue_obs", + label: "Revenue increased by 18%.", + description: "Revenue increased by 18%.", + kind: "observation", + status: "supported", + confidence: "high", + }), + makeNode({ + id: "n_cash_obs", + label: "Cash in the bank fell over the same period.", + description: "Cash in the bank fell.", + kind: "observation", + status: "supported", + confidence: "high", + }), + ], + edges: [], + activeUnknownNodeId: "n_explanation", + resolvedNodeIds: [], + currentSummary: "Explanation fixture", + }); + + const diagnosisChild = makeNode({ + id: "n_diagnosis_child", + label: "What practical issue is blocking progress", + description: + "Need to identify the specific blocking issue before continuing this line of work.", + kind: "unknown", + status: "unknown", + confidence: "medium", + parentId: "n_explanation", + }); + + graph.nodes.push(diagnosisChild); + + const compat = assessBoundedAdmission(graph, diagnosisChild); + const compatibility = assessReasoningPatternCompatibility({ + node: diagnosisChild, + graph, + activePattern: resolveActivePattern(graph), + activeNodeId: graph.activeUnknownNodeId, + }); + + expect(compat.admitted).toBe(false); + expect(compat.nodePattern).toBe("diagnosis"); + expect(compat.structuralEmbedding).toBe(false); + expect(compatibility.compatible).toBe(false); + }); + + it("Test 12 - pre-existing unknown does not enter fallback", () => { + const graph = buildApplyProposalDecisionFixture(); + const preExisting = makeNode({ + id: "n_preexisting_diagnosis", + label: "What practical risk affects this option", + description: + "Need to identify the concrete risk factor affecting this option.", + kind: "unknown", + status: "unknown", + confidence: "medium", + parentId: graph.activeUnknownNodeId, + }); + graph.nodes.push(preExisting); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + addedNodes: [], + updatedNodes: [ + { + nodeId: graph.activeUnknownNodeId, + previousStatus: "unknown", + newStatus: "known", + previousValue: null, + newValue: "Decision anchor updated for selection.", + reason: "Makes the parent answer-derived for the test.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: [], + selectedQuestion: { + nodeId: "n_preexisting_diagnosis", + question: "What practical risk affects this option?", + reason: "Pre-existing node should not gain fallback admission.", + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it("Test 13 - admitted node survives later result validation", () => { + const graph = buildApplyProposalDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + answer: "We need to identify the practical risk affecting this option.", + previousQuestion: "Should we relocate?", + proposal: { + addedNodes: [ + makeNode({ + id: "n_added_route_a", + label: "What practical risk affects this option", + description: + "Need to identify the concrete risk factor affecting this option because it matters to the decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + parentId: graph.activeUnknownNodeId, + }), + ], + updatedNodes: [ + { + nodeId: graph.activeUnknownNodeId, + previousStatus: "unknown", + newStatus: "provisional", + previousValue: null, + newValue: "Decision remains open pending factor clarification.", + reason: "The answer introduces a concrete follow-up factor.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: [], + selectedQuestion: { + nodeId: "n_added_route_a", + question: "What practical risk affects this option?", + reason: "New factor introduced this turn.", + }, + answerMeaning: { + userSupportedMeaning: + "We need to identify the practical risk affecting this option.", + possibleInference: null, + supportCategory: null, + resolutionGuidance: null, + }, + structuralActionRequired: true, + }, + }); + + expect(result.success).toBe(true); + expect(result.selectedQuestion?.nodeId).toBe("n_added_route_a"); + }); + + it("Test 14 - non-pattern validations still apply", () => { + const graph = buildApplyProposalDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + answer: "We need to identify the practical risk affecting this option.", + previousQuestion: "Should we relocate?", + proposal: { + addedNodes: [ + makeNode({ + id: "n_added_invalid", + label: "What practical risk affects this option", + description: + "Need to identify the concrete risk factor affecting this option because it matters to the decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + parentId: graph.activeUnknownNodeId, + }), + ], + updatedNodes: [ + { + nodeId: graph.activeUnknownNodeId, + previousStatus: "unknown", + newStatus: "provisional", + previousValue: null, + newValue: "Decision remains open pending factor clarification.", + reason: "The answer introduces a concrete follow-up factor.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: [], + selectedQuestion: { + nodeId: "n_added_invalid", + question: + "What practical risk affects this option, and how severe is it?", + reason: "Compound question should still fail.", + }, + answerMeaning: { + userSupportedMeaning: + "We need to identify the practical risk affecting this option.", + possibleInference: null, + supportCategory: null, + resolutionGuidance: null, + }, + structuralActionRequired: true, + }, + }); + + expect(result.success).toBe(false); + expect(result.errors.join(" ")).toContain( + "selectedQuestion must be a single non-compound question", + ); + }); +});