fix(reasoning): enforce terminal post-mutation eligibility

This commit is contained in:
2026-08-14 09:11:08 +01:00
parent 34eb0cd4e3
commit 6b13e67c05
4 changed files with 414 additions and 13 deletions
+8
View File
@@ -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):
+123
View File
@@ -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.
+8 -13
View File
@@ -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,
),
);
+275
View File
@@ -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();
});
});