diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 1ce54f5..77421be 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -234,6 +234,14 @@ Experiment 54N tested whether an interpretation disagreement can be judged for m - Focused preservation run passed via `npx vitest run tests/graph/apply-proposal.test.js -t "60B.42|60B.11|replaces downstream pricing"`. - Broader duplicated post-mutation eligibility checks remain unchanged; this fixes the selector contract only, not the full stale-question lifecycle. +### 60B.43 terminal post-mutation eligibility + +- Extended terminal-status exclusion across the remaining post-mutation eligibility checks in `lib/graph/apply-proposal.js` while leaving pre-mutation `validateSelectedQuestion(...)` unchanged. +- `isSelectableUnresolvedUnknown(...)`, `remainingUnknownExists`, carried-active reuse, unresolved/eligible candidate listing, and pattern-compatible post-mutation selection now all treat `known|resolved|contradicted` as terminal/non-selectable. +- Deterministic 60B.37-shaped closure regression now passes: proposal applies, `n_enterprise_customer_signing` resolves, `n_product_launch_decision` becomes known, `activeUnknownNodeId` becomes null, and final `selectedQuestion` becomes null. +- Focused fallback case also passes: when the proposal-selected target becomes known but another real unresolved unknown remains, the terminal target is discarded and the real unresolved node is selected. +- Focused preservation run passed via `npx vitest run tests/graph/apply-proposal.test.js -t "60B.43|60B.11|replaces downstream pricing"`; live rerun still required to prove the same outcome end-to-end under live conditions. + ### When This Knowledge-Management Phase Is Complete Provisional criteria for review (all confirmed met by Experiment 38 cold-start test): diff --git a/docs/experiment-60b43.md b/docs/experiment-60b43.md new file mode 100644 index 0000000..d6c053a --- /dev/null +++ b/docs/experiment-60b43.md @@ -0,0 +1,123 @@ +# Experiment 60B.43 — Terminal post-mutation eligibility + +**Date:** 2026-08-14 +**Branch:** `feature/post-mutation-terminal-eligibility-v0.38` + +## Purpose + +Extend the 60B.42 selector guard to the remaining post-mutation question-target eligibility checks so terminal-status unknown nodes cannot remain active or become the final selected question after mutation. + +## Starting point from 60B.42 + +60B.42 fixed `selectActiveUnknownCandidate(...)` so it no longer scores or returns unknown-kind nodes whose status is terminal: + +- `known` +- `resolved` +- `contradicted` + +That closed one fallback source, but several independent post-mutation checks in `apply-proposal.js` still used weaker eligibility rules and could keep terminal nodes alive through other paths. + +## Remaining post-mutation eligibility changes + +This experiment changed post-mutation eligibility only in `lib/graph/apply-proposal.js`. + +Updated paths: + +- `isSelectableUnresolvedUnknown(...)` +- `remainingUnknownExists` +- `carriedActiveUnknownStillUnresolved` +- `listUnresolvedUnknownCandidates(...)` +- `listEligibleUnknownCandidates(...)` +- `selectPatternCompatibleUnknownCandidate(...)` + +### Terminal-status rule applied post-mutation + +A selectable unresolved post-mutation target now requires: + +```text +kind === unknown +status NOT IN [known, resolved, contradicted] +not in resolvedNodeIds +``` + +### Important boundary preserved + +`validateSelectedQuestion(...)` was **not** changed. + +The closure proposal remains valid pre-mutation even when: + +- `selectedQuestion.nodeId = n_product_launch_decision` +- the same proposal updates `n_product_launch_decision -> known` + +Only after mutation is that now-terminal target discarded. + +## 60B.37 deterministic regression + +Added focused regression: + +- `60B.43 — terminal post-mutation target is cleared after valid decision closure` + +Reproduced the 60B.37-shaped closure: + +- existing active unknown: `n_enterprise_customer_signing` +- proposal resolves `n_enterprise_customer_signing` +- proposal updates `n_product_launch_decision -> known` +- proposal still selects `n_product_launch_decision` +- no added nodes or edges + +### Result + +- proposal applied successfully +- customer factor resolved in place +- decision became known in place +- both options preserved unchanged +- `activeUnknownNodeId = null` +- final `selectedQuestion = null` + +## Fallback-to-real-unknown result + +Added a second focused case where: + +- proposal-selected target becomes `known` +- another genuine unresolved unknown remains after mutation + +Result: + +- terminal known target discarded +- remaining genuine unresolved candidate selected + +## Known-only post-mutation result + +Added a valid mutated case where the final remaining unknown becomes `known` in the same mutation. + +Result: + +- `activeUnknownNodeId = null` +- `selectedQuestion = null` + +## Preservation checks + +Focused run also preserved: + +- 60B.11 preferred-target behaviour +- pricing prerequisite-first behaviour +- resolved exclusion +- contradicted exclusion + +## Validation + +Command run: + +```bash +npx vitest run tests/graph/apply-proposal.test.js -t "60B.43|60B.11|replaces downstream pricing" +``` + +Result: + +- PASS — `14 passed | 76 skipped` + +## What remains unproven until live rerun + +This deterministic bounded fix now clears the exact 60B.37-shaped stale target through the post-mutation production path under test. + +What remains unproven until a live rerun is whether the full runtime/orchestration path with the real customer-signing closure answer produces the same null-question closure end state under live conditions. \ No newline at end of file diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index 5baf149..cc582a6 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -1660,7 +1660,7 @@ function isSelectableUnresolvedUnknown(graph, nodeId) { return Boolean( node && node.kind === "unknown" && - !["resolved", "contradicted"].includes(node.status) && + !["known", "resolved", "contradicted"].includes(node.status) && !(graph.resolvedNodeIds || []).includes(node.id), ); } @@ -1673,9 +1673,7 @@ function listUnresolvedUnknownCandidates( return (graph.nodes || []).filter( (node) => - node.kind === "unknown" && - !["resolved", "contradicted"].includes(node.status) && - !(graph.resolvedNodeIds || []).includes(node.id) && + isSelectableUnresolvedUnknown(graph, node.id) && !resolvedCurrentTurnSet.has(node.id), ); } @@ -2183,7 +2181,7 @@ function selectPatternCompatibleUnknownCandidate({ (node) => node.kind === "unknown" && !excluded.has(node.id) && - !["resolved", "contradicted"].includes(node.status), + isSelectableUnresolvedUnknown(graph, node.id), ) .filter( (node) => @@ -3704,11 +3702,9 @@ export function applyValidatedProposal({ const remainingUnknownExists = newActiveUnknownNodeId != null && - updatedSituationGraph.nodes.some( - (node) => - node.id === newActiveUnknownNodeId && - node.kind === "unknown" && - !updatedSituationGraph.resolvedNodeIds.includes(node.id), + isSelectableUnresolvedUnknown( + updatedSituationGraph, + newActiveUnknownNodeId, ); if (!remainingUnknownExists) { @@ -3846,9 +3842,8 @@ export function applyValidatedProposal({ : null; const carriedActiveUnknownStillUnresolved = Boolean( carriedActiveUnknownNode && - carriedActiveUnknownNode.kind === "unknown" && - !["resolved", "contradicted"].includes(carriedActiveUnknownNode.status) && - !updatedSituationGraph.resolvedNodeIds.includes( + isSelectableUnresolvedUnknown( + updatedSituationGraph, carriedActiveUnknownNode.id, ), ); diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index 30138b6..4045dc3 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -4384,3 +4384,278 @@ describe("60B.11 — prerequisite-aware question targeting", () => { // The node has no depends_on prerequisites, so it passes the guard }); }); + +describe("60B.43 — terminal post-mutation target is cleared after valid decision closure", () => { + function makeProductLaunchClosureFixture({ includeFallbackUnknown = false } = {}) { + const productLaunchDecision = makeNode({ + id: "n_product_launch_decision", + label: "Which option leaves us better off overall?", + description: + "Uncertainty about which of the two product-launch timing options provides superior net value for the organisation.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const launchThisYear = makeNode({ + id: "opt_launch_this_year", + label: "Launch this year", + description: "Launch the new software product this year.", + kind: "option", + status: "known", + confidence: "high", + }); + const waitTwelveMonths = makeNode({ + id: "opt_wait_twelve_months", + label: "Wait twelve months", + description: "Wait twelve months before launching the product.", + kind: "option", + status: "known", + confidence: "high", + }); + const enterpriseCustomerSigning = makeNode({ + id: "n_enterprise_customer_signing", + label: "Prospective enterprise customer signing status", + description: + "Unknown whether one prospective enterprise customer will sign if we launch this year, because they account for approximately £700,000 of the £1.2 million expected annual revenue.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const nodes = [ + productLaunchDecision, + launchThisYear, + waitTwelveMonths, + enterpriseCustomerSigning, + ]; + const edges = [ + makeEdge({ + id: "e-opt-launch-to-dec", + fromNodeId: launchThisYear.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Launch this year option is a candidate for the product launch decision.", + }), + makeEdge({ + id: "e-opt-wait-to-dec", + fromNodeId: waitTwelveMonths.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Wait twelve months option is a candidate for the product launch decision.", + }), + makeEdge({ + id: "e-customer-signing-to-launch-option", + fromNodeId: enterpriseCustomerSigning.id, + toNodeId: launchThisYear.id, + relationship: "contained_in", + confidence: "high", + description: "Customer signing status is material to launching this year.", + }), + ]; + + if (includeFallbackUnknown) { + const fallbackUnknown = makeNode({ + id: "n_other_market_evidence", + label: "Other market evidence gap", + description: + "Need other market evidence because the remaining launch case still depends on it.", + kind: "unknown", + status: "unknown", + confidence: "high", + }); + nodes.push(fallbackUnknown); + edges.push( + makeEdge({ + id: "e-fallback-to-launch-option", + fromNodeId: fallbackUnknown.id, + toNodeId: launchThisYear.id, + relationship: "may_cause", + confidence: "medium", + description: "Fallback unresolved evidence remains material to launch timing.", + }), + ); + } + + const graph = makeGraph({ + centralStatement: + "We are evaluating two product-launch timing options: launching the new software product this year or waiting twelve months.", + nodes, + edges, + activeUnknownNodeId: enterpriseCustomerSigning.id, + resolvedNodeIds: [], + currentSummary: + "Customer signing is the active material uncertainty in the product-launch decision.", + }); + + return { + graph, + ids: { + productLaunchDecision: productLaunchDecision.id, + launchThisYear: launchThisYear.id, + waitTwelveMonths: waitTwelveMonths.id, + enterpriseCustomerSigning: enterpriseCustomerSigning.id, + fallbackUnknown: includeFallbackUnknown + ? "n_other_market_evidence" + : null, + }, + }; + } + + it("accepts the 60B.37-shaped closure proposal, resolves the customer factor, and clears the terminal decision target after mutation", () => { + const { graph, ids } = makeProductLaunchClosureFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: + "Prospective enterprise customer confirmed they will sign if we launch this year.", + reason: "The user directly answered the active customer-signing unknown.", + }, + { + nodeId: ids.productLaunchDecision, + previousStatus: "unknown", + newStatus: "known", + previousValue: null, + newValue: + "Launching this year is now known to be the better-supported option.", + reason: + "With customer signing confirmed and no other material uncertainties remaining, the decision is closed.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + selectedQuestion: { + nodeId: ids.productLaunchDecision, + question: "What outcome would demonstrate enough value to justify launching?", + reason: + "Regression case: proposal still points at the decision node even though it becomes known in the same turn.", + }, + }, + }); + + expect(result.success).toBe(true); + + const resolvedCustomer = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.enterpriseCustomerSigning, + ); + const knownDecision = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.productLaunchDecision, + ); + + expect(resolvedCustomer?.status).toBe("resolved"); + expect(knownDecision?.status).toBe("known"); + expect(result.updatedSituationGraph.nodes.filter((node) => node.id === ids.productLaunchDecision)).toHaveLength(1); + expect(result.updatedSituationGraph.nodes.filter((node) => node.id === ids.launchThisYear)).toHaveLength(1); + expect(result.updatedSituationGraph.nodes.filter((node) => node.id === ids.waitTwelveMonths)).toHaveLength(1); + expect(result.updatedSituationGraph.activeUnknownNodeId).toBeNull(); + expect(result.selectedQuestion).toBeNull(); + }); + + it("discards a proposal-selected target that becomes known and falls back to another genuine unresolved candidate", () => { + const { graph, ids } = makeProductLaunchClosureFixture({ + includeFallbackUnknown: true, + }); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed.", + reason: "The active customer-signing uncertainty is resolved.", + }, + { + nodeId: ids.productLaunchDecision, + previousStatus: "unknown", + newStatus: "known", + previousValue: null, + newValue: "The decision node itself becomes known.", + reason: "The originally selected target closes during mutation.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + selectedQuestion: { + nodeId: ids.productLaunchDecision, + question: "What outcome would demonstrate enough value to justify launching?", + reason: "This target becomes terminal and must be discarded post-mutation.", + }, + }, + }); + + expect(result.success).toBe(true); + expect(result.updatedSituationGraph.activeUnknownNodeId).toBe( + ids.fallbackUnknown, + ); + expect(result.selectedQuestion?.nodeId).toBe(ids.fallbackUnknown); + expect(result.selectedQuestion?.nodeId).not.toBe(ids.productLaunchDecision); + }); + + it("returns no active target and no final question when only terminal unknowns remain after a valid mutation", () => { + const graph = makeGraph({ + centralStatement: "Known-only terminal graph after mutation", + nodes: [ + makeNode({ + id: "n_known_terminal", + label: "Known terminal unknown", + description: "This unknown-kind node starts unresolved but becomes known.", + kind: "unknown", + status: "unknown", + confidence: "high", + }), + ], + edges: [], + activeUnknownNodeId: "n_known_terminal", + resolvedNodeIds: [], + currentSummary: "A single unknown remains before mutation.", + }); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: "n_known_terminal", + previousStatus: "unknown", + newStatus: "known", + previousValue: null, + newValue: "The previously active unknown is now known.", + reason: "The mutation closes the final unknown.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: ["n_known_terminal"], + selectedQuestion: { + nodeId: "n_known_terminal", + question: "What remains to be clarified?", + reason: "This target becomes terminal in the same mutation and must be cleared post-mutation.", + }, + }, + }); + + expect(result.success).toBe(true); + expect(result.updatedSituationGraph.activeUnknownNodeId).toBeNull(); + expect(result.selectedQuestion).toBeNull(); + }); +});