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:
@@ -408,6 +408,139 @@ function reconcileResolutionSemantics(graph, proposal) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── 60B.80 — decision closure ownership normalisation ──────────────
|
||||
|
||||
/**
|
||||
* Determines whether a node is a parent decision by containment edges
|
||||
* established in 60B.75 (unknown node with incoming contained_in from options).
|
||||
*/
|
||||
function isParentDecision(nodeId, graphNodes) {
|
||||
const node = graphNodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.kind !== "unknown") return false;
|
||||
if (
|
||||
!(graphNodes[Symbol.for("edges")] || []).some(
|
||||
(e) => e.relationship === "contained_in" && e.toNodeId === nodeId,
|
||||
)
|
||||
) {
|
||||
// Check via graph edges array passed separately
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasIncomingContainedInEdge(nodeId, graphEdges) {
|
||||
return (graphEdges || []).some(
|
||||
(e) => e.relationship === "contained_in" && e.toNodeId === nodeId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a proposal attempts terminal closure of an unresolved
|
||||
* parent decision without explicit user confirmation. If so, strips ONLY
|
||||
* the parent terminal update and its resolved bookkeeping, preserving all
|
||||
* other proposal content (customer/factor resolution, option updates,
|
||||
* addedNodes/addedEdges, answerMeaning, etc.).
|
||||
*
|
||||
* Runs AFTER reconcileResolutionSemantics, BEFORE proposal compatibility
|
||||
* validation. Receives raw answer to detect confirmation absence.
|
||||
*
|
||||
* 60B.79 established: the model must not make a parent decision terminal
|
||||
* merely because represented evidence appears sufficient. The user's
|
||||
* explicit confirmation is the sole authority for closing a parent decision.
|
||||
*/
|
||||
function reconcileDecisionClosureOwnership(graph, proposal, answer) {
|
||||
const TERMINAL_STATUSES = ["known", "resolved", "contradicted"];
|
||||
const changesMade = { strippedUpdates: [], strippedResolvedIds: [] };
|
||||
|
||||
// Build set of graph node IDs that are parent decisions (unknown with
|
||||
// incoming contained_in edges from option nodes). This is the canonical
|
||||
// decision-context mechanism established in 60B.75 — decisions are not a
|
||||
// separate kind; they are unknown nodes identified by containment structure.
|
||||
const parentNodeIds = new Set();
|
||||
for (const node of graph.nodes || []) {
|
||||
if (node.kind !== "unknown") continue;
|
||||
if (!hasIncomingContainedInEdge(node.id, graph.edges)) continue;
|
||||
// Also verify the node itself is currently unresolved
|
||||
if (TERMINAL_STATUSES.includes(node.status)) continue;
|
||||
parentNodeIds.add(node.id);
|
||||
}
|
||||
|
||||
if (parentNodeIds.size === 0) return changesMade;
|
||||
|
||||
// Check explicit confirmation on answer (bounded phrase/pattern family)
|
||||
const hasConfirmation = isUserConfirmationOfNoRemainingUncertainty(answer);
|
||||
|
||||
if (hasConfirmation) return changesMade;
|
||||
|
||||
// ── Phase A: Strip terminal updates from updatedNodes ────────────
|
||||
for (const update of proposal.updatedNodes || []) {
|
||||
if (!parentNodeIds.has(update.nodeId)) continue;
|
||||
if (!TERMINAL_STATUSES.includes(update.newStatus)) continue;
|
||||
|
||||
const idx = proposal.updatedNodes.indexOf(update);
|
||||
if (idx === -1) continue;
|
||||
|
||||
// Revert status to previous (or unknown)
|
||||
update.newStatus = "unknown";
|
||||
|
||||
// Restore original value state — must remove directional newValue
|
||||
// because 60B.79: hybrid unknown+value on decision is semantically unsafe
|
||||
update.previousValue ??= update.newValue ?? null;
|
||||
update.newValue = null;
|
||||
|
||||
changesMade.strippedUpdates.push(update.nodeId);
|
||||
}
|
||||
|
||||
// ── Phase B: Strip parent from resolvedUnknownNodeIds ────────────
|
||||
const updatedNodeIds = new Set(
|
||||
(proposal.updatedNodes || []).map((u) => u.nodeId),
|
||||
);
|
||||
|
||||
for (let i = proposal.resolvedUnknownNodeIds.length - 1; i >= 0; i--) {
|
||||
const resolvedId = proposal.resolvedUnknownNodeIds[i];
|
||||
if (!parentNodeIds.has(resolvedId)) continue;
|
||||
|
||||
// Strip from resolved list
|
||||
proposal.resolvedUnknownNodeIds.splice(i, 1);
|
||||
changesMade.strippedResolvedIds.push(resolvedId);
|
||||
|
||||
// If reconciliation synthesized a forced "resolved" update for this node,
|
||||
// revert it (it was not model-provided — it was synthetic)
|
||||
const existingUpdate = proposal.updatedNodes.find(
|
||||
(u) => u.nodeId === resolvedId,
|
||||
);
|
||||
if (existingUpdate && updatedNodeIds.has(resolvedId)) {
|
||||
// Only revert if the status is "resolved" due to reconciliation
|
||||
// forcing it (the model might have also proposed it — in which case
|
||||
// Phase A already stripped it but left newStatus="unknown")
|
||||
if (existingUpdate.newStatus === "resolved") {
|
||||
existingUpdate.newStatus = "unknown";
|
||||
if (existingUpdate.previousValue === undefined) {
|
||||
existingUpdate.previousValue = existingUpdate.newValue ?? null;
|
||||
}
|
||||
existingUpdate.newValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If no explicit update exists for this resolved node, create a minimal
|
||||
// unknown-preserving update to prevent reconciliation errors downstream
|
||||
if (!existingUpdate) {
|
||||
const parentNode = graph.nodes.find((n) => n.id === resolvedId);
|
||||
if (parentNode) {
|
||||
proposal.updatedNodes.push({
|
||||
nodeId: resolvedId,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "unknown",
|
||||
previousValue: parentNode.value ?? null,
|
||||
newValue: parentNode.value ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changesMade;
|
||||
}
|
||||
|
||||
function validateSemanticDuplicateUnknowns(graph, proposal) {
|
||||
const errors = [];
|
||||
const unresolvedUnknowns = graph.nodes.filter(
|
||||
@@ -3546,6 +3679,27 @@ export function applyValidatedProposal({
|
||||
situationGraph,
|
||||
proposalValidation.data,
|
||||
);
|
||||
|
||||
// ── 60B.80 — normalise terminal parent closure without explicit confirmation
|
||||
const ownershipChanges = reconcileDecisionClosureOwnership(
|
||||
situationGraph,
|
||||
reconciledProposal.proposal,
|
||||
answer,
|
||||
);
|
||||
if (ownershipChanges.strippedResolvedIds.length > 0) {
|
||||
// Reconciler may have added selectedQuestion = null because the parent
|
||||
// appeared resolved during reconciliation. If we stripped it, restore
|
||||
// minimal state so normal downstream question reselection can proceed.
|
||||
const strippedParentIds = new Set(ownershipChanges.strippedResolvedIds);
|
||||
if (
|
||||
reconciledProposal.proposal.selectedQuestion &&
|
||||
strippedParentIds.has(reconciledProposal.proposal.selectedQuestion.nodeId)
|
||||
) {
|
||||
// Allow existing target/question reselection to work naturally
|
||||
// rather than synthesising question text here.
|
||||
}
|
||||
}
|
||||
|
||||
const validatedProposal = reconciledProposal.proposal;
|
||||
const proposalCompatibilityErrors = [];
|
||||
proposalCompatibilityErrors.push(...reconciledProposal.errors);
|
||||
|
||||
@@ -140,7 +140,7 @@ An unresolved decision between options should not remain open merely because som
|
||||
|
||||
Keep a decision context unresolved only when you can identify a specific unresolved factor that could materially change which option is preferred.
|
||||
|
||||
If the currently supported evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve the existing decision context and do not ask a generic continuation question.
|
||||
You may not resolve the decision context unless the user explicitly confirms (using their own words) that no other material uncertainty remains. If evidence appears sufficient but explicit confirmation is absent, preserve your directional conclusion in possibleInference and allow the system to ask the sufficiency confirmation / discovery question rather than closing the parent decision.
|
||||
|
||||
## Decision Option Structure Rules
|
||||
When the user presents mutually exclusive candidate actions for one unresolved choice:
|
||||
|
||||
@@ -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