fix(reasoning): enforce confirmation-gated decision closure
- reconcileDecisionClosureOwnership normaliser between reconciliation and validation (Boundary B) - Strips terminal parent updates without explicit user confirmation; preserves all other proposal work - Strips parent from resolvedUnknownNodeIds bookkeeping on no-confirmation strip - Restores reconciler-forced resolved→unknown for synthetic updates too - Prevents hybrid unknown+value states by nulling newValue in all stripping paths - No-op update created when reconciler synthesized the entry to prevent downstream errors Prompt: - Rule #143 rewritten from evidence-sufficiency to explicit-confirmation gate - Directs model to use possibleInference for directional conclusions when confirmation absent Regression preservation: - 60B.43 lifecycle invariant restored via explicit confirmation phrases in fixture answers - 60B.49 reconciliation auto-add invariant restored under confirmed closure flow - Test apparatus fixed: structuralActionRequired required with userSupportedMeaning (validator constraint) New coverage: - 10 tests for all 60B.79/80 coverage requirements - 5 prompt alignment tests for Rule #143
This commit is contained in:
@@ -4517,7 +4517,7 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue:
|
||||
"Prospective enterprise customer confirmed they will sign if we launch this year.",
|
||||
"Prospective enterprise customer confirmed they will sign if we launch this year. No other material uncertainty remains.",
|
||||
reason: "The user directly answered the active customer-signing unknown.",
|
||||
},
|
||||
{
|
||||
@@ -4543,6 +4543,7 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
|
||||
"Regression case: proposal still points at the decision node even though it becomes known in the same turn.",
|
||||
},
|
||||
},
|
||||
answer: "no remaining material uncertainty",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -4575,7 +4576,7 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer signing confirmed.",
|
||||
newValue: "Customer signing confirmed. No other material uncertainty remains.",
|
||||
reason: "The active customer-signing uncertainty is resolved.",
|
||||
},
|
||||
{
|
||||
@@ -4598,9 +4599,17 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
|
||||
reason: "This target becomes terminal and must be discarded post-mutation.",
|
||||
},
|
||||
},
|
||||
answer: "no remaining material uncertainty",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// With explicit confirmation, decision closes normally; fallback to next unresolved unknown
|
||||
// so fallbackUnknown remains active; selectedQuestion falls back correctly.
|
||||
const productDecisionNode = result.updatedSituationGraph.nodes.find(
|
||||
(n) => n.id === ids.productLaunchDecision,
|
||||
);
|
||||
// With explicit confirmation, decision closes normally; fallback to next unresolved unknown
|
||||
expect(productDecisionNode?.status).toBe("known");
|
||||
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe(
|
||||
ids.fallbackUnknown,
|
||||
);
|
||||
@@ -4737,7 +4746,7 @@ describe("60B.49 — reconciles updated-to-resolved unknown into resolvedUnknown
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer confirmed they will not sign this year.",
|
||||
newValue: "Customer confirmed they will not sign this year. No other material uncertainty remains.",
|
||||
reason: "Negative outcome resolves the customer factor.",
|
||||
},
|
||||
{
|
||||
@@ -4760,23 +4769,19 @@ describe("60B.49 — reconciles updated-to-resolved unknown into resolvedUnknown
|
||||
reason: "Closure-shaped proposal contract.",
|
||||
},
|
||||
},
|
||||
answer: "no remaining material uncertainty",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.updatedSituationGraph.resolvedNodeIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
enterpriseCustomerSigning.id,
|
||||
productLaunchDecision.id,
|
||||
]),
|
||||
);
|
||||
// With explicit confirmation, parent closure proceeds normally:
|
||||
expect(
|
||||
result.updatedSituationGraph.resolvedNodeIds.filter(
|
||||
(id) => id === productLaunchDecision.id,
|
||||
(id) => id === enterpriseCustomerSigning.id,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
result.updatedSituationGraph.resolvedNodeIds.filter(
|
||||
(id) => id === enterpriseCustomerSigning.id,
|
||||
(id) => id === productLaunchDecision.id,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
@@ -5783,3 +5788,636 @@ describe("60B.64 — explicit decision sufficiency closure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── 60B.80 — confirmation-gated model closure ownership ─────────────
|
||||
|
||||
describe("60B.80 — confirmation-gated model closure ownership", () => {
|
||||
function makeClosureProposalFixture() {
|
||||
const decision = makeNode({
|
||||
id: "n_product_launch_decision",
|
||||
label: "Which option leaves us better off overall?",
|
||||
description: "Uncertainty about which product-launch timing provides superior net value.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const optA = 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 optB = 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 customerFactor = makeNode({
|
||||
id: "n_enterprise_customer_signing",
|
||||
label: "Prospective enterprise customer signing status",
|
||||
description: "Unknown whether one prospective enterprise customer will sign.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const marketFactor = makeNode({
|
||||
id: "n_market_timing_factor",
|
||||
label: "Competing product announcement timing",
|
||||
description: "Unknown whether a competitor will announce in Q3.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const nodes = [decision, optA, optB, customerFactor, marketFactor];
|
||||
const edges = [
|
||||
makeEdge({
|
||||
id: "e-opt-a-to-dec",
|
||||
fromNodeId: optA.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-opt-b-to-dec",
|
||||
fromNodeId: optB.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-customer-to-optA",
|
||||
fromNodeId: customerFactor.id,
|
||||
toNodeId: optA.id,
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-market-to-dec",
|
||||
fromNodeId: marketFactor.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
}),
|
||||
];
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Evaluating two product-launch timing options.",
|
||||
nodes,
|
||||
edges,
|
||||
activeUnknownNodeId: customerFactor.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Customer signing is the active uncertainty.",
|
||||
});
|
||||
|
||||
return { graph, ids: { decision: decision.id, optA: optA.id, optB: optB.id, customerFactor: customerFactor.id, marketFactor: marketFactor.id } };
|
||||
}
|
||||
|
||||
// ── Test 1 — resolved/null parent closure stripped (no confirmation) ──
|
||||
it("Test 1 — resolved/null parent closure stripped", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.customerFactor,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer will not sign if we launch this year.",
|
||||
reason: "Active unknown resolved.",
|
||||
},
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: null,
|
||||
reason: "All factors resolved.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "Customer will not sign",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
|
||||
// Customer resolution preserved
|
||||
expect(customer?.status).toBe("resolved");
|
||||
// Decision closure removed
|
||||
expect(decision?.status).toBe("unknown");
|
||||
// Decision removed from resolvedUnknownNodeIds — consistency check via graph
|
||||
const updatedProposal = result.updatedSituationGraph;
|
||||
expect(updatedProposal.resolvedNodeIds).not.toContain(ids.decision);
|
||||
expect(updatedProposal.resolvedNodeIds).toContain(ids.customerFactor);
|
||||
});
|
||||
|
||||
// ── Test 2 — known/directional parent closure stripped ──
|
||||
it("Test 2 — known/directional parent closure stripped", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.customerFactor,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer confirmed.",
|
||||
reason: "Resolved.",
|
||||
},
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "launch this year",
|
||||
reason: "Directional conclusion.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "Customer confirmed",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
|
||||
// Customer resolution preserved
|
||||
expect(customer?.status).toBe("resolved");
|
||||
// Parent terminal update removed entirely
|
||||
expect(decision?.status).toBe("unknown");
|
||||
// Directional value NOT retained on unknown decision
|
||||
expect(decision?.value).toBeNull();
|
||||
});
|
||||
|
||||
// ── Test 3 — resolved/directional parent closure stripped ──
|
||||
it("Test 3 — resolved/directional parent closure stripped", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "launch this year",
|
||||
reason: "Resolved directionally.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "Launch is better",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
|
||||
// Terminal parent update removed
|
||||
expect(decision?.status).toBe("unknown");
|
||||
// No hybrid unknown + directional value
|
||||
expect(decision?.value).toBeNull();
|
||||
});
|
||||
|
||||
// ── Test 4 — explicit confirmation allows closure ──
|
||||
it("Test 4 — explicit confirmation allows closure", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.customerFactor,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer confirmed.",
|
||||
reason: "Resolved.",
|
||||
},
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "launch this year",
|
||||
reason: "All factors resolved.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "There are no other material uncertainties remain.",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
|
||||
// Explicit confirmation permits closure: normalisation strips model's terminal
|
||||
// update, then deterministic gate re-closes the decision because confirmation is present.
|
||||
expect(customer?.status).toBe("resolved");
|
||||
expect(decision?.status).toBe("resolved");
|
||||
});
|
||||
|
||||
// ── Test 5 — ordinary child unknown resolution unaffected ──
|
||||
it("Test 5 — ordinary child unknown resolution unaffected", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.marketFactor,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "No competitor announcement expected.",
|
||||
reason: "Resolved by evidence.",
|
||||
},
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "launch this year",
|
||||
reason: "Terminal closure.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.marketFactor, ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "No competitor announcement expected",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const market = result.updatedSituationGraph.nodes.find((n) => n.id === ids.marketFactor);
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
|
||||
|
||||
// market is NOT a parent decision (no contained_in from options), so its
|
||||
// terminal update survives regardless of confirmation absence. Decision IS a parent,
|
||||
// and without explicit confirmation its closure gets stripped.
|
||||
// customerFactor is not referenced in this test proposal and stays unknown.
|
||||
expect(market?.status).toBe("resolved");
|
||||
expect(decision?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
// ── Test 6 — unrelated option/evidence updates survive ──
|
||||
it("Test 6 — unrelated option/evidence updates survive", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.customerFactor,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer confirmed.",
|
||||
reason: "Resolved.",
|
||||
},
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "launch this year",
|
||||
reason: "Terminal closure.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "Customer confirmed",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
|
||||
// Customer resolution preserved
|
||||
expect(customer?.status).toBe("resolved");
|
||||
// Decision closure stripped (no explicit confirmation phrase in answer)
|
||||
expect(decision?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
// ── Test 7 — answerMeaning survives normalisation ──
|
||||
it("Test 7 — answerMeaning survives normalisation", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.customerFactor,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer confirmed.",
|
||||
reason: "Resolved.",
|
||||
},
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "launch this year",
|
||||
reason: "Terminal closure.",
|
||||
},
|
||||
],
|
||||
answerMeaning: {
|
||||
userSupportedMeaning: "Customer confirmed they will not sign.",
|
||||
supportCategory: "other",
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "Customer confirmed",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
|
||||
|
||||
// Decision closure stripped (no explicit confirmation phrase in answer)
|
||||
expect(decision?.status).toBe("unknown");
|
||||
// Customer resolution preserved
|
||||
expect(customer?.status).toBe("resolved");
|
||||
});
|
||||
|
||||
// ── Test 8 — resolved bookkeeping remains consistent ──
|
||||
it("Test 8 — resolved bookkeeping remains consistent", () => {
|
||||
const { graph, ids } = makeClosureProposalFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.customerFactor,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer confirmed.",
|
||||
reason: "Resolved.",
|
||||
},
|
||||
{
|
||||
nodeId: ids.decision,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: null,
|
||||
reason: "Parent closure.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
|
||||
affectedNodeIds: [ids.decision],
|
||||
},
|
||||
answer: "Customer confirmed",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
|
||||
const resolvedIds = result.updatedSituationGraph.resolvedNodeIds;
|
||||
|
||||
// No parent in resolvedUnknownNodeIds after stripped closure
|
||||
expect(resolvedIds).not.toContain(ids.decision);
|
||||
// Customer remains present
|
||||
expect(resolvedIds).toContain(ids.customerFactor);
|
||||
// Decision remains unknown
|
||||
expect(decision?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
// ── Test 9 — State B becomes reachable ──
|
||||
it("Test 9 — State B sufficiency question path reached", () => {
|
||||
const decision = makeNode({
|
||||
id: "n_product_launch_decision",
|
||||
label: "Which option leaves us better off overall?",
|
||||
description: "Uncertainty about which product-launch timing provides superior net value.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const optA = 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 optB = makeNode({
|
||||
id: "opt_wait_twelve_months",
|
||||
label: "Wait twelve months",
|
||||
description: "Wait twelve months before launching.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const customerFactor = makeNode({
|
||||
id: "n_customer_signing",
|
||||
label: "Enterprise customer signing status",
|
||||
description: "Unknown whether enterprise customer will sign.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const nodes = [decision, optA, optB, customerFactor];
|
||||
const edgesArr = [
|
||||
makeEdge({ id: "e1", fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in", confidence: "high" }),
|
||||
makeEdge({ id: "e2", fromNodeId: optB.id, toNodeId: decision.id, relationship: "contained_in", confidence: "high" }),
|
||||
makeEdge({ id: "e3", fromNodeId: customerFactor.id, toNodeId: optA.id, relationship: "may_cause", confidence: "medium" }),
|
||||
];
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Evaluating product-launch timing.",
|
||||
nodes,
|
||||
edges: edgesArr,
|
||||
activeUnknownNodeId: decision.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Decision remains unresolved.",
|
||||
});
|
||||
|
||||
// Model tries to close the parent without explicit confirmation
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: customerFactor.id,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Customer signed.",
|
||||
reason: "Resolved.",
|
||||
},
|
||||
{
|
||||
nodeId: decision.id,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: null,
|
||||
reason: "All factors resolved — closing parent.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [customerFactor.id, decision.id],
|
||||
affectedNodeIds: [decision.id],
|
||||
},
|
||||
answer: "Customer signed. That resolves it.",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const decisionNode = result.updatedSituationGraph.nodes.find((n) => n.id === decision.id);
|
||||
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === customerFactor.id);
|
||||
|
||||
// Decision remains unknown (closure stripped)
|
||||
expect(decisionNode?.status).toBe("unknown");
|
||||
// activeUnknownNodeId = the unresolved decision
|
||||
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe(decision.id);
|
||||
// Customer resolution preserved
|
||||
expect(customer?.status).toBe("resolved");
|
||||
});
|
||||
|
||||
// ── Test 10 — ordinary decision_threshold unaffected ──
|
||||
it("Test 10 — ordinary decision_threshold preserved", () => {
|
||||
const decision = makeNode({
|
||||
id: "n_regular_decision",
|
||||
label: "Which vendor to choose?",
|
||||
description: "Decision between three vendors.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const optA = makeNode({
|
||||
id: "opt_vendor_a",
|
||||
label: "Vendor A",
|
||||
description: "Vendor A option.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const remainingFactor = makeNode({
|
||||
id: "n_pricing_details",
|
||||
label: "Remaining pricing details unknown",
|
||||
description: "Need pricing details from Vendor A.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const nodes = [decision, optA, remainingFactor];
|
||||
const edgesArr = [
|
||||
makeEdge({ id: "e1", fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in", confidence: "high" }),
|
||||
makeEdge({ id: "e2", fromNodeId: remainingFactor.id, toNodeId: optA.id, relationship: "may_cause", confidence: "medium" }),
|
||||
];
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Evaluating vendor options.",
|
||||
nodes,
|
||||
edges: edgesArr,
|
||||
activeUnknownNodeId: decision.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Pricing details still unknown.",
|
||||
});
|
||||
|
||||
// Model resolves customer-like factor but not the remaining material factor
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: optA.id,
|
||||
previousStatus: "known",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "Vendor A selected.",
|
||||
reason: "Directional update only.",
|
||||
},
|
||||
],
|
||||
addedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [decision.id],
|
||||
},
|
||||
answer: "We've looked at Vendor A thoroughly.",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Decision_threshold_outcome behaviour unchanged — decision stays open
|
||||
const decisionNode = result.updatedSituationGraph.nodes.find((n) => n.id === decision.id);
|
||||
expect(decisionNode?.status).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -863,6 +863,64 @@ describe("60B.4 decision materiality rule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 60B.80 — explicit confirmation requirement in prompt
|
||||
// ============================================
|
||||
|
||||
describe("60B.80 — prompt alignment for confirmation-gated closure", () => {
|
||||
let sufficiencySection;
|
||||
|
||||
beforeAll(() => {
|
||||
const testNode = makeNode({
|
||||
id: "n-test-decision",
|
||||
label: "Test Decision",
|
||||
kind: "state",
|
||||
status: "supported",
|
||||
});
|
||||
const prompt = buildGraphUpdatePrompt({
|
||||
situationGraph: makeGraph({
|
||||
centralStatement: "test",
|
||||
nodes: [testNode],
|
||||
edges: [],
|
||||
activeUnknownNodeId: null,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Test.",
|
||||
}),
|
||||
situationContext: "Test context",
|
||||
});
|
||||
sufficiencySection = prompt.split("## Decision Sufficiency Rule")[1].split("## Decision Option Structure Rules")[0];
|
||||
});
|
||||
|
||||
it("explicit confirmation requirement is present in the Decision Sufficiency Rule", () => {
|
||||
expect(sufficiencySection).toContain("explicitly confirms");
|
||||
expect(sufficiencySection).toContain("no other material uncertainty remains");
|
||||
});
|
||||
|
||||
it("evidence-only terminal closure rule is replaced by confirmation-gated rule", () => {
|
||||
// The OLD rule that permitted evidence-only closure is replaced:
|
||||
expect(sufficiencySection).not.toContain(
|
||||
"If the currently supported evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve the existing decision context",
|
||||
);
|
||||
// The NEW confirmation requirement is present:
|
||||
expect(sufficiencySection).toContain("explicit confirmation");
|
||||
});
|
||||
|
||||
it("model must not close parent merely because evidence appears sufficient", () => {
|
||||
expect(sufficiencySection).toContain("You may not resolve the decision context unless");
|
||||
expect(sufficiencySection).toContain("explicitly confirms (using their own words)");
|
||||
});
|
||||
|
||||
it("preserves directional conclusion guidance when confirmation absent", () => {
|
||||
expect(sufficiencySection).toContain("preserve your directional conclusion in possibleInference");
|
||||
});
|
||||
|
||||
it("does NOT still permit evidence-only terminal closure", () => {
|
||||
// The old "resolve" permission based on evidence sufficiency alone is gone:
|
||||
const text = sufficiencySection;
|
||||
expect(text).not.toContain("evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 60B.11 — prompt prerequisite-aware targeting clarity
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user