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);
|
||||
|
||||
Reference in New Issue
Block a user