fix(reasoning): enforce terminal post-mutation eligibility
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user