diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index 2235811..1c99be4 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -2843,6 +2843,59 @@ function deriveAnswerMeaningProfile(userSupportedMeaning) { }; } +function validateAnswerMeaningCompatibilityWithRawAnswer({ answer, proposal }) { + if (!answer || !proposal.answerMeaning) return []; + + const errors = []; + const rawAnswerProfile = deriveAnswerMeaningProfile(answer); + const supportedMeaningProfile = deriveAnswerMeaningProfile( + proposal.answerMeaning.userSupportedMeaning, + ); + const supportedMeaningText = normaliseSemanticText( + proposal.answerMeaning.userSupportedMeaning, + ); + + if (rawAnswerProfile.category === "relative_priority_only") { + if (supportedMeaningProfile.category !== "relative_priority_only") { + errors.push( + "answerMeaning.userSupportedMeaning introduces a stronger reasoning category than the raw answer establishes.", + ); + } + + if (containsConstraintBoundaryLanguage(supportedMeaningText)) { + errors.push( + "answerMeaning.userSupportedMeaning introduces an unsupported constraint or preference/trade-off distinction not present in the raw answer.", + ); + } + } + + if (rawAnswerProfile.category === "conditional_tradeoff") { + if (supportedMeaningProfile.category !== "conditional_tradeoff") { + errors.push( + "answerMeaning.userSupportedMeaning loses the raw answer's conditional trade-off structure.", + ); + } + } + + if (rawAnswerProfile.category === "uncertain") { + if (supportedMeaningProfile.category !== "uncertain") { + errors.push( + "answerMeaning.userSupportedMeaning overstates a raw answer that remains uncertain.", + ); + } + } + + if (rawAnswerProfile.category === "explicit_hard_constraint") { + if (supportedMeaningProfile.category !== "explicit_hard_constraint") { + errors.push( + "answerMeaning.userSupportedMeaning weakens a raw answer that explicitly states a hard constraint.", + ); + } + } + + return errors; +} + function validateAnswerMeaningAlignment(proposal) { if (!proposal.answerMeaning) return []; @@ -3092,6 +3145,12 @@ export function applyValidatedProposal({ validatedProposal, ); proposalCompatibilityErrors.push(...selectedQuestionValidation.errors); + proposalCompatibilityErrors.push( + ...validateAnswerMeaningCompatibilityWithRawAnswer({ + answer, + proposal: validatedProposal, + }), + ); proposalCompatibilityErrors.push( ...validateAnswerMeaningAlignment(validatedProposal), ); diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index 00d5981..08510ed 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -1079,6 +1079,54 @@ describe("applyValidatedProposal", () => { expect(result.errors.join(" ")).toContain("must remain unresolved"); }); + it("Regression A: rejects unsupported strengthening inside userSupportedMeaning itself", () => { + const { graph, riskUnknownId } = makeRiskClarificationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + answer: "Risk matters more to me.", + previousQuestion: + "Is avoiding additional risk a hard constraint or a preference/trade-off?", + proposal: { + addedNodes: [], + updatedNodes: [ + { + nodeId: riskUnknownId, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: + "Avoiding additional risk is a preference/trade-off rather than a hard constraint.", + reason: + "The answer was interpreted as ruling out a hard constraint.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [riskUnknownId], + affectedNodeIds: [], + selectedQuestion: null, + answerMeaning: { + userSupportedMeaning: + "Avoiding additional risk is a preference/trade-off rather than a hard constraint.", + possibleInference: + "The user prioritizes risk mitigation over aggressive growth strategies.", + supportCategory: "relative_priority_only", + resolutionGuidance: "must_remain_unresolved", + }, + }, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_compatibility"); + expect(result.errors.join(" ")).toContain( + "stronger reasoning category than the raw answer establishes", + ); + expect(result.errors.join(" ")).toContain( + "unsupported constraint or preference/trade-off distinction", + ); + }); + it("Regression B: rejects conditional trade-off proposals that flatten the qualification", () => { const { graph, riskUnknownId } = makeRiskClarificationFixture(); @@ -1306,6 +1354,127 @@ describe("applyValidatedProposal", () => { ); }); + it("Regression B: preserves conditional trade-off when userSupportedMeaning stays within the raw answer", () => { + const { graph, riskUnknownId } = makeRiskClarificationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + answer: + "I'd normally avoid more risk, but for the right opportunity I might accept some.", + previousQuestion: + "Is avoiding additional risk a hard constraint or a preference/trade-off?", + proposal: { + addedNodes: [ + makeNode({ + id: "n-opportunity-criteria", + label: "What counts as the right opportunity", + description: + "Need to know what counts as the right opportunity because that determines when some additional risk would be acceptable.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }), + ], + updatedNodes: [ + { + nodeId: riskUnknownId, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: + "The user would normally avoid more risk, but for the right opportunity might accept some.", + reason: + "The answer establishes a conditional trade-off rather than a flat hard constraint.", + }, + ], + addedEdges: [ + makeEdge({ + id: "e-risk-opportunity-criteria", + fromNodeId: riskUnknownId, + toNodeId: "n-opportunity-criteria", + relationship: "depends_on", + confidence: "medium", + description: + "The unresolved opportunity threshold matters because it determines when the trade-off changes.", + }), + ], + removedEdgeIds: [], + resolvedUnknownNodeIds: [riskUnknownId], + affectedNodeIds: [], + selectedQuestion: { + nodeId: "n-opportunity-criteria", + question: + "What would count as the right opportunity for accepting some additional risk?", + reason: + "The conditional threshold remains unresolved and is the next consequential unknown.", + }, + answerMeaning: { + userSupportedMeaning: + "The user would normally avoid more risk, but for the right opportunity might accept some.", + possibleInference: + "The exact threshold for the right opportunity remains undefined.", + supportCategory: "conditional_tradeoff", + resolutionGuidance: "may_resolve", + }, + }, + }); + + expect(result.success).toBe(true); + expect(result.updatedSituationGraph.resolvedNodeIds).toContain( + riskUnknownId, + ); + expect( + result.updatedSituationGraph.nodes.some( + (node) => node.id === "n-opportunity-criteria", + ), + ).toBe(true); + }); + + it("Inference separation: possibleInference may remain plausible but cannot justify graph mutation when userSupportedMeaning overstates the raw answer", () => { + const { graph, riskUnknownId } = makeRiskClarificationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + answer: "Risk matters more to me.", + previousQuestion: + "Is avoiding additional risk a hard constraint or a preference/trade-off?", + proposal: { + addedNodes: [], + updatedNodes: [ + { + nodeId: riskUnknownId, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: + "Avoiding additional risk is a preference rather than a hard constraint.", + reason: + "The interpretation was treated as sufficient to resolve the distinction.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [riskUnknownId], + affectedNodeIds: [], + selectedQuestion: null, + answerMeaning: { + userSupportedMeaning: + "Avoiding additional risk is a preference rather than a hard constraint.", + possibleInference: + "The user may be signaling caution and a willingness to trade off growth for lower risk.", + supportCategory: "other", + resolutionGuidance: "may_resolve", + }, + }, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_compatibility"); + expect(result.errors.join(" ")).toContain( + "unsupported constraint or preference/trade-off distinction", + ); + }); + it("Regression C: rejects unresolved uncertainty being treated as resolved", () => { const { graph, riskUnknownId } = makeRiskClarificationFixture();