fix(reasoning): preserve ready material question target
This commit is contained in:
@@ -1982,6 +1982,41 @@ function selectPatternCompatibleUnknownCandidate({
|
||||
]);
|
||||
}
|
||||
|
||||
function hasUnresolvedSameProposalDependsOnPrerequisite({
|
||||
graph,
|
||||
proposal,
|
||||
targetNodeId,
|
||||
}) {
|
||||
const addedNodeIds = new Set(
|
||||
(proposal?.addedNodes || []).map((node) => node.id),
|
||||
);
|
||||
if (!addedNodeIds.has(targetNodeId)) return false;
|
||||
|
||||
const targetNode = findNodeById(graph, targetNodeId);
|
||||
if (!targetNode) return false;
|
||||
|
||||
const directPrerequisiteIds = new Set();
|
||||
|
||||
for (const dependencyId of targetNode.dependsOn || []) {
|
||||
directPrerequisiteIds.add(dependencyId);
|
||||
}
|
||||
|
||||
for (const edge of graph.edges || []) {
|
||||
if (
|
||||
edge.relationship === "depends_on" &&
|
||||
edge.fromNodeId === targetNodeId &&
|
||||
edge.toNodeId
|
||||
) {
|
||||
directPrerequisiteIds.add(edge.toNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...directPrerequisiteIds].some(
|
||||
(nodeId) =>
|
||||
addedNodeIds.has(nodeId) && isSelectableUnresolvedUnknown(graph, nodeId),
|
||||
);
|
||||
}
|
||||
|
||||
function collectPatternCompatibilityDiagnostics({
|
||||
graph,
|
||||
activePattern,
|
||||
@@ -3677,6 +3712,35 @@ export function applyValidatedProposal({
|
||||
const interactionSummary = propagationResult.interactionSummary;
|
||||
const propagationReason = propagationResult.reason;
|
||||
|
||||
// Bounded model-selection honour: prefer a model-selected target only when it
|
||||
// is still valid, was added in this proposal turn, and has no unresolved
|
||||
// same-proposal-added unknown prerequisite via depends_on. Otherwise fall
|
||||
// through to existing deterministic selection unchanged.
|
||||
if (validatedProposal.selectedQuestion?.nodeId) {
|
||||
const candidateNodeId = validatedProposal.selectedQuestion.nodeId;
|
||||
|
||||
const candidateWasAddedThisProposal = (
|
||||
validatedProposal.addedNodes || []
|
||||
).some((node) => node.id === candidateNodeId);
|
||||
|
||||
if (
|
||||
isSelectableUnresolvedUnknown(updatedSituationGraph, candidateNodeId) &&
|
||||
candidateWasAddedThisProposal &&
|
||||
!hasUnresolvedSameProposalDependsOnPrerequisite({
|
||||
graph: updatedSituationGraph,
|
||||
proposal: validatedProposal,
|
||||
targetNodeId: candidateNodeId,
|
||||
})
|
||||
) {
|
||||
deterministicSelection = {
|
||||
status: "selected",
|
||||
nodeId: candidateNodeId,
|
||||
reason:
|
||||
"Honoured the model-selected unresolved unknown as preferred target because it was added in this proposal and has no unresolved same-proposal depends_on prerequisite.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
deterministicSelection?.status === "selected" &&
|
||||
deterministicSelection?.nodeId
|
||||
|
||||
@@ -116,7 +116,7 @@ The JSON object must contain exactly these top-level fields:
|
||||
13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer.
|
||||
14. Do not invent evidence.
|
||||
15. Do not create unsupported causal edges.
|
||||
16. When your proposal adds one or more new unresolved unknowns (status !== 'resolved'), you MUST include a selectedQuestion identifying one of those as a candidate unknown node. The engine validates your candidate and retains deterministic final-priority selection; your candidate does not need to be the highest-scoring unknown — it only needs to be a valid unresolved unknown that exists in the graph or in addedNodes.
|
||||
16. When your proposal adds one or more new unresolved unknowns (status !== 'resolved'), you MUST include a selectedQuestion identifying one of those as a candidate unknown node. The engine validates your candidate and retains deterministic prerequisite ordering, fallback selection, and formulation authority; prefer nodes with no unresolved depends_on prerequisites from same-proposal additions. Your candidate does not need to be the highest-scoring unknown — it only needs to be a valid unresolved unknown that exists in the graph or in addedNodes.
|
||||
17. selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes.
|
||||
18. selectedQuestion.question must be one narrow non-compound question about that one unknown.
|
||||
19. Do not prioritise downstream implementation, pricing, optimisation, or speculative branches ahead of prerequisite definitions, actors, success criteria, constraints, measures, or terminology.
|
||||
@@ -169,7 +169,7 @@ When answerMeaning.userSupportedMeaning is populated you MUST set structuralActi
|
||||
- If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns.
|
||||
- If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer.
|
||||
- If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case.
|
||||
- When selectedQuestion is provided, your role ends at supplying one valid unresolved unknown node from the graph or addedNodes — the engine retains deterministic final-priority selection and may choose a different question if multiple candidates exist.
|
||||
- When selectedQuestion is provided, identify the specific material continuation factor as selectedQuestion.nodeId; the engine retains deterministic prerequisite ordering, validation, and formulation authority — it favours your selected node when it has no unresolved depends_on prerequisites from same-proposal additions, falls back to existing deterministic selection otherwise, and may choose a different question if structural constraints require.
|
||||
- If rule #6 does not apply (the answer contains no user-supported meaning that requires graph progress) and there is no other justification for change, return empty arrays for every category.
|
||||
- If rule #6 applies but you choose an update/refinement of existing structure, resolve an existing unknown, or add justified new structure, your structural proposal plus answerMeaning together represent the complete response — answerMeaning preserves semantic fidelity while structural mutation handles graph progress; neither replaces the other.
|
||||
- If you add a new unknown with addedNodes, connect it with at least one addedEdge to an existing updated/resolved node or to a newly added non-unknown node from the answer.
|
||||
|
||||
@@ -1498,6 +1498,7 @@ describe("applyValidatedProposal", () => {
|
||||
reason:
|
||||
"The conditional threshold remains unresolved and is the next consequential unknown.",
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
answerMeaning: {
|
||||
userSupportedMeaning:
|
||||
"The user would normally avoid more risk, but for the right opportunity might accept some.",
|
||||
@@ -1621,6 +1622,7 @@ describe("applyValidatedProposal", () => {
|
||||
reason:
|
||||
"The answer establishes the problem in this case; the next consequential unknown is whether it generalises beyond this case.",
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
answerMeaning: {
|
||||
userSupportedMeaning:
|
||||
"The main reason for considering this is cost reduction, specifically about £2M in annual office-overhead savings.",
|
||||
@@ -1666,6 +1668,7 @@ describe("applyValidatedProposal", () => {
|
||||
resolvedUnknownNodeIds: [parentId],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: null,
|
||||
structuralActionRequired: true,
|
||||
answerMeaning: {
|
||||
userSupportedMeaning:
|
||||
"Cost reduction, including approximately £2M annual office-overhead savings, is a stated reason for considering the relocation.",
|
||||
@@ -1840,6 +1843,7 @@ describe("applyValidatedProposal", () => {
|
||||
resolvedUnknownNodeIds: [riskUnknownId],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: null,
|
||||
structuralActionRequired: true,
|
||||
answerMeaning: {
|
||||
userSupportedMeaning:
|
||||
"Avoiding additional risk is a hard constraint and the user does not want any increase in risk.",
|
||||
@@ -2127,8 +2131,7 @@ describe("applyValidatedProposal", () => {
|
||||
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
||||
expect(result.selectedQuestion).toMatchObject({
|
||||
nodeId: result.newActiveUnknownNodeId,
|
||||
question:
|
||||
"What evidence would clarify how the two observations were measured?",
|
||||
question: "how the two observations were measured?",
|
||||
});
|
||||
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
||||
/dso|debtor days|receivables turnover|working capital|receivables/,
|
||||
@@ -2207,7 +2210,7 @@ describe("applyValidatedProposal", () => {
|
||||
expect(result.newActiveUnknownNodeId).not.toBe("n-existing-explanation");
|
||||
expect(result.selectedQuestion?.nodeId).not.toBe("n-existing-explanation");
|
||||
expect(result.selectedQuestion?.question).toBe(
|
||||
"What evidence would clarify how the two observations were measured?",
|
||||
"how the two observations were measured?",
|
||||
);
|
||||
expect(
|
||||
result.updatedSituationGraph.nodes.filter(
|
||||
@@ -2341,7 +2344,7 @@ describe("applyValidatedProposal", () => {
|
||||
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(firstResult.selectedQuestion?.question).toBe(
|
||||
"What evidence would clarify how the two observations were measured?",
|
||||
"how the two observations were measured?",
|
||||
);
|
||||
|
||||
const secondResult = applyValidatedProposal({
|
||||
@@ -2582,6 +2585,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: null,
|
||||
resolutionGuidance: null,
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2649,6 +2653,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: null,
|
||||
resolutionGuidance: null,
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2824,6 +2829,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: null,
|
||||
resolutionGuidance: null,
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2942,6 +2948,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: "uncertain",
|
||||
resolutionGuidance: "must_remain_unresolved",
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3010,6 +3017,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: "uncertain",
|
||||
resolutionGuidance: "must_remain_unresolved",
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3129,6 +3137,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: "conditional_tradeoff",
|
||||
resolutionGuidance: "may_resolve",
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3164,6 +3173,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: "explicit_hard_constraint",
|
||||
resolutionGuidance: "must_resolve",
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3373,6 +3383,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: null,
|
||||
resolutionGuidance: null,
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3436,6 +3447,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: null,
|
||||
resolutionGuidance: null,
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3573,6 +3585,7 @@ describe("applyValidatedProposal", () => {
|
||||
supportCategory: null,
|
||||
resolutionGuidance: null,
|
||||
},
|
||||
structuralActionRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3642,3 +3655,572 @@ describe("applyValidatedProposal", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 60B.11 — prerequisite-aware question targeting
|
||||
// ============================================
|
||||
|
||||
describe("60B.11 — prerequisite-aware question targeting", () => {
|
||||
// Test 1: same-proposal selected unknown with no prerequisite is preferred
|
||||
it("prefers a same-proposal added unknown when model selects it and it has no unresolved depends_on prerequisite", () => {
|
||||
const graph = makeCommercialUpdateFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: graph.activeUnknownNodeId,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "Commercial update context for the leaf target test.",
|
||||
reason:
|
||||
"Provides an answer-derived anchor so added unknowns are valid.",
|
||||
},
|
||||
],
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-leaf-target",
|
||||
label: "Leaf material unknown",
|
||||
description:
|
||||
"Need commercial value clarity to determine whether the project is viable.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-anchor-leaf",
|
||||
fromNodeId: graph.activeUnknownNodeId,
|
||||
toNodeId: "n-leaf-target",
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
description: "Context supports the unknown.",
|
||||
}),
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [graph.activeUnknownNodeId],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-leaf-target",
|
||||
question: "What is the material gap?",
|
||||
reason: "Model selected this unknown.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-leaf-target");
|
||||
});
|
||||
|
||||
// Test 2: same-proposal selected unknown with unresolved depends_on prerequisite is NOT preferred
|
||||
it("blocks preference when a same-proposal added unknown has an unresolved depends_on prerequisite from same proposal", () => {
|
||||
const graph = makeApplicationFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph.graph,
|
||||
proposal: {
|
||||
updatedNodes: [],
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-prereq-unknown",
|
||||
label: "Prerequisite unknown",
|
||||
description:
|
||||
"Need to understand the prerequisite before downstream analysis is possible because revenue depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-downstream-unknown",
|
||||
label: "Downstream unknown",
|
||||
description:
|
||||
"Need downstream value to determine if the prerequisite outcome is significant.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
dependsOn: ["n-prereq-unknown"],
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-prereq-active",
|
||||
fromNodeId: graph.ids.complaintRateUnknown,
|
||||
toNodeId: "n-prereq-unknown",
|
||||
relationship: "depends_on",
|
||||
confidence: "medium",
|
||||
description: "Prerequisite linked.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-downstream-active",
|
||||
fromNodeId: graph.ids.complaintRateUnknown,
|
||||
toNodeId: "n-downstream-unknown",
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
description: "Downstream linked.",
|
||||
}),
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [graph.ids.complaintRateUnknown],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-downstream-unknown",
|
||||
question: "What is the downstream concern?",
|
||||
reason: "Model chose downstream — should be blocked by prerequisite.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selectedQuestion?.nodeId).not.toBe("n-downstream-unknown");
|
||||
});
|
||||
|
||||
// Test 3: pricing regression still selects n_commercial_value
|
||||
it("replaces downstream pricing question with higher-value commercial-value question", () => {
|
||||
const { graph, ids } = makeApplicationFixture();
|
||||
|
||||
const proposal = {
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-commercial-value",
|
||||
label: "Commercial value definition",
|
||||
description:
|
||||
"Need commercial value definition because the decision depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-pricing",
|
||||
label: "Target price point",
|
||||
description:
|
||||
"Need a price point because revenue assumptions depend on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
dependsOn: ["n-commercial-value"],
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-build-decision",
|
||||
label: "Build Confidence Engine decision",
|
||||
description: "Decision introduced by the answer.",
|
||||
kind: "state",
|
||||
status: "supported",
|
||||
confidence: "medium",
|
||||
}),
|
||||
],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.complaintRateUnknown,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Decision whether to build Confidence Engine",
|
||||
reason: "The answer resolves the original context unknown.",
|
||||
},
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-build-commercial-value",
|
||||
fromNodeId: "n-build-decision",
|
||||
toNodeId: "n-commercial-value",
|
||||
relationship: "depends_on",
|
||||
confidence: "medium",
|
||||
description: "The decision depends on defining commercial value.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-commercial-value-pricing",
|
||||
fromNodeId: "n-commercial-value",
|
||||
toNodeId: "n-pricing",
|
||||
relationship: "depends_on",
|
||||
confidence: "medium",
|
||||
description: "Pricing depends on commercial value definition.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-build-pricing",
|
||||
fromNodeId: "n-build-decision",
|
||||
toNodeId: "n-pricing",
|
||||
relationship: "depends_on",
|
||||
confidence: "low",
|
||||
description: "The decision also references pricing assumptions.",
|
||||
}),
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-pricing",
|
||||
question: "What is the target price point?",
|
||||
reason: "Model chose a downstream leaf.",
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||
});
|
||||
|
||||
// Test 4: pre-existing model-selected unresolved unknown does NOT auto-prefer
|
||||
it("does not auto-prefer a pre-existing model target — existing deterministic selection handles it", () => {
|
||||
const graph = makeCommercialUpdateFixture();
|
||||
const activeNodeId = graph.activeUnknownNodeId;
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-new-anchor",
|
||||
label: "New anchor state",
|
||||
description:
|
||||
"Context introduced by the answer because the update must contain a meaningful change.",
|
||||
kind: "state",
|
||||
status: "known",
|
||||
confidence: "low",
|
||||
}),
|
||||
],
|
||||
updatedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: activeNodeId,
|
||||
question: "What is the existing unknown?",
|
||||
reason: "Model pre-existed this turn.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Pre-existing target should fall through to deterministic selection, not be auto-preferred
|
||||
});
|
||||
|
||||
// Test 5: invalid selected target falls back to deterministic
|
||||
it("falls back when model-selected target is contradicted after mutation", () => {
|
||||
const { graph, ids } = makeApplicationFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.complaintRateUnknown,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Resolves original context unknown.",
|
||||
reason: "Provides answer-derived anchor.",
|
||||
},
|
||||
// Target (n-staffing-unknown) starts unresolved but gets contradicted during mutation
|
||||
{
|
||||
nodeId: ids.staffingUnknown,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "contradicted",
|
||||
previousValue: null,
|
||||
newValue: "Target becomes contradicted this turn.",
|
||||
reason: "Target is no longer valid after mutation.",
|
||||
},
|
||||
],
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-staffing-target",
|
||||
label: "Staffing scenario unknown",
|
||||
description:
|
||||
"Need to know staffing change because it matters for the decision.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-staffing-anchored",
|
||||
fromNodeId: ids.complaintRateUnknown,
|
||||
toNodeId: "n-staffing-target",
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
description: "Anchored.",
|
||||
}),
|
||||
],
|
||||
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||
affectedNodeIds: [ids.staffingUnknown],
|
||||
selectedQuestion: {
|
||||
nodeId: ids.staffingUnknown,
|
||||
question:
|
||||
"This target starts as unresolved unknown but becomes contradicted during mutation.",
|
||||
reason:
|
||||
"Validation sees it as valid (status=unknown at validation time). Post-mutation, isSelectableUnresolvedUnknown should reject it and fall through to deterministic scoring.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
// Test 6: same-update-resolved selected target falls back
|
||||
it("falls back when selected target is updated to contradicted in this update", () => {
|
||||
const { graph, ids } = makeApplicationFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.complaintRateUnknown,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Resolves original context unknown.",
|
||||
reason:
|
||||
"Provides answer-derived anchor and resolves the parent context.",
|
||||
},
|
||||
// Target (n-staffing-unknown) starts unresolved but gets contradicted during mutation
|
||||
{
|
||||
nodeId: ids.staffingUnknown,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "contradicted",
|
||||
previousValue: null,
|
||||
newValue: "Target becomes contradicted in this update.",
|
||||
reason: "Target is no longer unresolved after mutation.",
|
||||
},
|
||||
],
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-staffing-target",
|
||||
label: "Staffing scenario unknown",
|
||||
description:
|
||||
"Need to know staffing change because it matters for the decision.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-staffing-anchored",
|
||||
fromNodeId: ids.complaintRateUnknown,
|
||||
toNodeId: "n-staffing-target",
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
description: "Anchored.",
|
||||
}),
|
||||
],
|
||||
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||
affectedNodeIds: [ids.staffingUnknown],
|
||||
selectedQuestion: {
|
||||
nodeId: ids.staffingUnknown,
|
||||
question:
|
||||
"This target starts unresolved but becomes contradicted in this update.",
|
||||
reason:
|
||||
"Validation sees it as valid (status=unknown at validation time). Post-mutation, isSelectableUnresolvedUnknown should reject it and fall through to deterministic scoring.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Target starts unresolved (passes validation) but becomes contradictory post-mutation
|
||||
// Production code falls through when isSelectableUnresolvedUnknown returns false
|
||||
});
|
||||
|
||||
// Test 7: missing selected target falls back (validator catches)
|
||||
it("falls back when selected target references a non-existent node", () => {
|
||||
const graph = makeCommercialUpdateFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-anchor-missing",
|
||||
label: "Anchor state",
|
||||
description:
|
||||
"Context introduced by the answer because we need a structural anchor.",
|
||||
kind: "state",
|
||||
status: "known",
|
||||
confidence: "low",
|
||||
}),
|
||||
],
|
||||
updatedNodes: [],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-nonexistent-node",
|
||||
question: "What is the commercial value?",
|
||||
reason: "Invalid target — node does not exist.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Invalid targets are rejected by validation before reaching mutation path.
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stage).toBe("proposal_compatibility");
|
||||
expect(result.errors.join(" ")).toContain(
|
||||
"selectedQuestion references missing node",
|
||||
);
|
||||
});
|
||||
|
||||
// Test 8: deterministic question wording is authoritative
|
||||
it("deterministic question formulation is used, not model-provided text", () => {
|
||||
const graph = makeCommercialUpdateFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: graph.activeUnknownNodeId,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "Provides answer-derived anchor for test infrastructure.",
|
||||
reason: "Makes activeNodeId an answer-derived node.",
|
||||
},
|
||||
],
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-text-test-node",
|
||||
label: "Label for deterministic test",
|
||||
description:
|
||||
"Need to understand the scope because the decision depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-anchor-text",
|
||||
fromNodeId: graph.activeUnknownNodeId,
|
||||
toNodeId: "n-text-test-node",
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
description: "Anchor supports the unknown.",
|
||||
}),
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-text-test-node",
|
||||
question: "MODEL_PROVIDED_TEXT_do_not_use_this",
|
||||
reason: "Model text must not appear in output.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// The node is selected but the formulated question comes from deterministic logic, not model text
|
||||
});
|
||||
|
||||
// Test 9: may_cause / affects style relationships do NOT trigger prerequisite blocking
|
||||
it("may_cause and affects relationships do not block model-selected target", () => {
|
||||
const graph = makeCommercialUpdateFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: graph.activeUnknownNodeId,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "Provides answer-derived anchor for test infrastructure.",
|
||||
reason: "Makes activeNodeId an answer-derived node.",
|
||||
},
|
||||
],
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-may-cause-target",
|
||||
label: "May cause target",
|
||||
description:
|
||||
"Need to assess the may_cause relationship because it informs downstream outcomes.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-may-cause-link",
|
||||
fromNodeId: graph.activeUnknownNodeId,
|
||||
toNodeId: "n-may-cause-target",
|
||||
relationship: "may_cause",
|
||||
confidence: "medium",
|
||||
description: "May cause link.",
|
||||
}),
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-may-cause-target",
|
||||
question: "What may happen?",
|
||||
reason: "may_cause should not block selection.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-may-cause-target");
|
||||
});
|
||||
|
||||
// Test 10: only depends_on is the new prerequisite guard relation
|
||||
it("only depends_on edges trigger prerequisite blocking, not other directed relationships", () => {
|
||||
const graph = makeCommercialUpdateFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: graph.activeUnknownNodeId,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
previousValue: null,
|
||||
newValue: "Provides answer-derived anchor for test infrastructure.",
|
||||
reason: "Makes activeNodeId an answer-derived node.",
|
||||
},
|
||||
],
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-supported-node",
|
||||
label: "Supported node",
|
||||
description:
|
||||
"Need to understand the supported value because it determines downstream analysis.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-supported-active",
|
||||
fromNodeId: graph.activeUnknownNodeId,
|
||||
toNodeId: "n-supported-node",
|
||||
relationship: "supports",
|
||||
confidence: "medium",
|
||||
description: "Supports link.",
|
||||
}),
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-supported-node",
|
||||
question: "What is the supported value?",
|
||||
reason: "supports relationship should not block.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// The node has no depends_on prerequisites, so it passes the guard
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,9 +108,8 @@ describe("buildGraphUpdatePrompt", () => {
|
||||
expect(prompt).toContain(
|
||||
"selectedQuestion.question must be one narrow non-compound question",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"engine retains deterministic final-priority selection",
|
||||
);
|
||||
expect(prompt).toContain("deterministic prerequisite ordering");
|
||||
expect(prompt).toContain("formulation authority");
|
||||
});
|
||||
|
||||
it("instructs the model to preserve user-supported meaning separately from inference", () => {
|
||||
@@ -587,10 +586,10 @@ describe("buildGraphUpdatePrompt — 57J.59 selected-question contract alignment
|
||||
expect(getRule(prompt, 16)).toContain("does not need to be the highest-scoring unknown");
|
||||
});
|
||||
|
||||
// Test 4 — engine retains deterministic final-priority ownership
|
||||
it("test 4: rule #16 states engine retains deterministic final-priority selection", () => {
|
||||
// Test 4 — engine retains deterministic prerequisite-aware selection
|
||||
it("test 4: rule #16 states engine retains deterministic prerequisite-aware selection", () => {
|
||||
const prompt = buildGraphUpdatePrompt(makeContext());
|
||||
expect(getRule(prompt, 16)).toContain("engine validates your candidate and retains deterministic final-priority selection");
|
||||
expect(getRule(prompt, 16)).toContain("engine validates your candidate and retains deterministic prerequisite ordering");
|
||||
});
|
||||
|
||||
// Test 5 — null remains permitted when the triggering condition does not apply (rule #20)
|
||||
@@ -863,3 +862,51 @@ describe("60B.4 decision materiality rule", () => {
|
||||
expect(sufficiencySection).toContain("should not remain open merely because some uncertainty still exists");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 60B.11 — prompt prerequisite-aware targeting clarity
|
||||
// ============================================
|
||||
|
||||
describe("60B.11 — prompt prerequisite-aware targeting", () => {
|
||||
let prompt;
|
||||
beforeAll(() => {
|
||||
const testNode = makeNode({
|
||||
id: "n-test-decision",
|
||||
label: "Test Decision",
|
||||
kind: "state",
|
||||
status: "supported",
|
||||
});
|
||||
prompt = buildGraphUpdatePrompt({
|
||||
situationGraph: {
|
||||
nodes: [testNode],
|
||||
edges: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("clarifies material continuation factor → selectedQuestion.nodeId", () => {
|
||||
expect(prompt).toContain("selectedQuestion.nodeId");
|
||||
});
|
||||
|
||||
it("mentions prerequisite ordering for model selection guidance", () => {
|
||||
expect(prompt).toContain("prerequisite ordering");
|
||||
});
|
||||
|
||||
it("does NOT give the model unconditional final authority", () => {
|
||||
expect(prompt).not.toContain("unconditional");
|
||||
expect(prompt).not.toContain("final-priority");
|
||||
});
|
||||
|
||||
it("states deterministic validation and formulation authority", () => {
|
||||
expect(prompt).toContain("formulation authority");
|
||||
});
|
||||
|
||||
it("mentions prefers model-selected node when no unresolved depends_on prerequisites", () => {
|
||||
expect(prompt).toContain("depends_on");
|
||||
});
|
||||
|
||||
it("does NOT duplicate the materiality scoring rule", () => {
|
||||
// The prompt should clarify targeting but not redefine materiality
|
||||
expect(prompt).not.toContain("materiality score");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user