fix(reasoning): preserve ready material question target

This commit is contained in:
2026-08-13 09:36:10 +01:00
parent 854c3aa002
commit 54bc48342b
4 changed files with 705 additions and 12 deletions
+586 -4
View File
@@ -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
});
});
+53 -6
View File
@@ -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");
});
});