diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index e667543..c2e565d 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -2745,14 +2745,70 @@ function proposalResolutionSummary(proposal) { return { resolved, proposalText }; } +function deriveAnswerMeaningProfile(userSupportedMeaning) { + const meaningText = normaliseSemanticText(userSupportedMeaning); + + if ( + meaningText.includes("not really sure") || + meaningText.includes("not sure") || + meaningText.includes("unsure") || + meaningText.includes("do not know") || + meaningText.includes("don't know") + ) { + return { + category: "uncertain", + resolutionGuidance: "must_remain_unresolved", + }; + } + + if ( + meaningText.includes("hard constraint") || + meaningText.includes("non-negotiable") || + meaningText.includes("dont want any increase in risk") || + meaningText.includes("do not want any increase in risk") + ) { + return { + category: "explicit_hard_constraint", + resolutionGuidance: "must_resolve", + }; + } + + if (hasConditionalQualification(meaningText)) { + return { + category: "conditional_tradeoff", + resolutionGuidance: "may_resolve", + }; + } + + if ( + meaningText.includes("matters more") || + meaningText.includes("more important") || + meaningText.includes("higher priority") || + meaningText.includes("greater relative importance") || + meaningText.includes("relative importance") + ) { + return { + category: "relative_priority_only", + resolutionGuidance: "must_remain_unresolved", + }; + } + + return { + category: "other", + resolutionGuidance: null, + }; +} + function validateAnswerMeaningAlignment(proposal) { if (!proposal.answerMeaning) return []; const errors = []; - const { userSupportedMeaning, supportCategory, resolutionGuidance } = - proposal.answerMeaning; + const { userSupportedMeaning } = proposal.answerMeaning; const meaningText = normaliseSemanticText(userSupportedMeaning); const { resolved, proposalText } = proposalResolutionSummary(proposal); + const derivedProfile = deriveAnswerMeaningProfile(userSupportedMeaning); + const supportCategory = derivedProfile.category; + const resolutionGuidance = derivedProfile.resolutionGuidance; if (resolutionGuidance === "must_remain_unresolved" && resolved) { errors.push( @@ -2810,6 +2866,14 @@ function validateAnswerMeaningAlignment(proposal) { } } + if (supportCategory === "other") { + if (resolved || containsConstraintBoundaryLanguage(proposalText)) { + errors.push( + "Proposal cannot resolve or strengthen answerMeaning that does not clearly establish one of the protected reasoning categories.", + ); + } + } + return errors; } diff --git a/lib/graph/prompt-builder.js b/lib/graph/prompt-builder.js index 9c5dc46..7c41a3b 100644 --- a/lib/graph/prompt-builder.js +++ b/lib/graph/prompt-builder.js @@ -116,10 +116,10 @@ The JSON object must contain exactly these top-level fields: 25. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal. 26. answerMeaning.userSupportedMeaning must state only what the user's answer directly supports. 27. Put any stronger interpretation in answerMeaning.possibleInference, not in userSupportedMeaning. -28. If the answer is only a relative priority statement, use supportCategory=relative_priority_only and resolutionGuidance=must_remain_unresolved. +28. supportCategory and resolutionGuidance are optional descriptive hints only; if you are unsure of the exact wording, leave them null rather than inventing rigid category labels. 29. If the answer is conditional or qualified, preserve that qualification explicitly in userSupportedMeaning. -30. If the answer says the user is unsure or does not resolve the distinction, use supportCategory=uncertain and resolutionGuidance=must_remain_unresolved. -31. If the answer explicitly states a hard constraint, use supportCategory=explicit_hard_constraint and resolutionGuidance=must_resolve. +30. If the answer says the user is unsure or does not resolve the distinction, state that uncertainty directly in userSupportedMeaning. +31. If the answer explicitly states a hard constraint, state that directly in userSupportedMeaning. ## Additional Guidance - If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes. diff --git a/lib/graph/schema.js b/lib/graph/schema.js index aaf3e5f..d5168fa 100644 --- a/lib/graph/schema.js +++ b/lib/graph/schema.js @@ -162,8 +162,8 @@ export const answerMeaningSchema = z .object({ userSupportedMeaning: z.string().min(1), possibleInference: z.string().nullable().optional(), - supportCategory: z.enum(Object.values(answerSupportCategory)), - resolutionGuidance: z.enum(Object.values(answerResolutionGuidance)), + supportCategory: z.string().min(1).nullable().optional(), + resolutionGuidance: z.string().min(1).nullable().optional(), }) .strict(); diff --git a/lib/graph/update-proposal.js b/lib/graph/update-proposal.js index 91a5f27..8e462ea 100644 --- a/lib/graph/update-proposal.js +++ b/lib/graph/update-proposal.js @@ -11,10 +11,6 @@ const TOP_LEVEL_ARRAY_FIELDS = [ const TOP_LEVEL_NULLABLE_FIELDS = ["selectedQuestion"]; -const ANSWER_SUPPORT_CATEGORY_ALIASES = { - conditional_qualification: "conditional_tradeoff", -}; - function cloneJsonSafe(value) { if (value == null) return value; return JSON.parse(JSON.stringify(value)); @@ -66,20 +62,6 @@ function applyKnownEnumAliases(proposal, normalisationsApplied) { }); } - const supportCategory = proposal.answerMeaning?.supportCategory; - const canonicalSupportCategory = - ANSWER_SUPPORT_CATEGORY_ALIASES[supportCategory]; - if (canonicalSupportCategory) { - normalisationsApplied.push({ - path: ["answerMeaning", "supportCategory"], - change: `Converted ${supportCategory} to ${canonicalSupportCategory}`, - }); - proposal.answerMeaning = { - ...proposal.answerMeaning, - supportCategory: canonicalSupportCategory, - }; - } - return proposal; } diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index d535d65..a05d55e 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -1123,7 +1123,7 @@ describe("applyValidatedProposal", () => { expect(result.errors.join(" ")).toContain("conditional qualification"); }); - it("accepts equivalent conditional category labels and still applies the existing Regression B guard", () => { + it("accepts the Experiment 56A supportCategory wording variant and still applies the existing Regression B guard", () => { const { graph, riskUnknownId } = makeRiskClarificationFixture(); const result = applyValidatedProposal({ @@ -1156,8 +1156,53 @@ describe("applyValidatedProposal", () => { "The user would normally avoid more risk, but for the right opportunity might accept some.", possibleInference: "This may support eventual clarification, but the qualifying condition remains material.", - supportCategory: "conditional_tradeoff", - resolutionGuidance: "may_resolve", + supportCategory: "conditional_qualification", + resolutionGuidance: "may resolve once the condition is clarified", + }, + }, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_compatibility"); + expect(result.errors.join(" ")).toContain("conditional qualification"); + }); + + it("accepts the Experiment 56B live wording variants and still applies the existing Regression B guard", () => { + 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: [], + updatedNodes: [ + { + nodeId: riskUnknownId, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: + "Avoiding additional risk is a preference or trade-off rather than a hard constraint.", + reason: + "The answer shows a preference or trade-off rather than a hard constraint.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [riskUnknownId], + affectedNodeIds: [], + selectedQuestion: null, + answerMeaning: { + userSupportedMeaning: + "The user would normally avoid more risk, but for the right opportunity might accept some.", + possibleInference: + "This may support eventual clarification, but the qualifying condition remains material.", + supportCategory: "conditional_preference", + resolutionGuidance: + "Identify and quantify the threshold conditions that trigger risk acceptance.", }, }, }); @@ -1296,6 +1341,50 @@ describe("applyValidatedProposal", () => { ); }); + it("fails safely when answerMeaning does not clearly establish one of the protected categories", () => { + const { graph, riskUnknownId } = makeRiskClarificationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + answer: "Risk and growth are both important.", + 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 answer was interpreted as a balanced trade-off with no hard constraint.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [riskUnknownId], + affectedNodeIds: [], + selectedQuestion: null, + answerMeaning: { + userSupportedMeaning: "Risk and growth are both important.", + possibleInference: + "The user may be describing a broad balance, but no constraint boundary is established.", + supportCategory: "constraint_preference_mix", + resolutionGuidance: "needs more nuance", + }, + }, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_compatibility"); + expect(result.errors.join(" ")).toContain( + "does not clearly establish one of the protected reasoning categories", + ); + }); + it("active unknown matches selected question node", () => { const { graph, ids } = makeApplicationFixture(); diff --git a/tests/graph/prompt-builder.test.js b/tests/graph/prompt-builder.test.js index 33086b9..e308245 100644 --- a/tests/graph/prompt-builder.test.js +++ b/tests/graph/prompt-builder.test.js @@ -122,7 +122,10 @@ describe("buildGraphUpdatePrompt", () => { "Put any stronger interpretation in answerMeaning.possibleInference", ); expect(prompt).toContain( - "If the answer is only a relative priority statement, use supportCategory=relative_priority_only and resolutionGuidance=must_remain_unresolved", + "supportCategory and resolutionGuidance are optional descriptive hints only", + ); + expect(prompt).toContain( + "leave them null rather than inventing rigid category labels", ); }); }); diff --git a/tests/graph/schema.test.js b/tests/graph/schema.test.js index 49f6ea6..8f608e9 100644 --- a/tests/graph/schema.test.js +++ b/tests/graph/schema.test.js @@ -212,8 +212,8 @@ describe("graphUpdateSchema", () => { userSupportedMeaning: "Risk matters more to me.", possibleInference: "This may imply caution, but does not establish a hard constraint.", - supportCategory: "relative_priority_only", - resolutionGuidance: "must_remain_unresolved", + supportCategory: "relative priority only", + resolutionGuidance: "leave unresolved", }, }); expect(result.success).toBe(true); diff --git a/tests/graph/update-proposal.test.js b/tests/graph/update-proposal.test.js index 82d2242..82d571c 100644 --- a/tests/graph/update-proposal.test.js +++ b/tests/graph/update-proposal.test.js @@ -75,7 +75,7 @@ describe("parseGraphUpdateProposal", () => { expect(result.normalisationsApplied.length).toBeGreaterThan(0); }); - it("normalises confirmed enum alias and preserves IDs", () => { + it("normalises confirmed node-kind alias and preserves IDs", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), addedNodes: [ @@ -96,12 +96,35 @@ describe("parseGraphUpdateProposal", () => { }, ], }); + expect(result.success).toBe(true); expect(result.proposal.addedNodes[0].kind).toBe("reported_claim"); expect(result.proposal.addedNodes[0].id).toBe("n-new"); }); - it("normalises equivalent answerMeaning supportCategory labels to the canonical contract", () => { + it("accepts live-style free-text answerMeaning hints without requiring enum tokens", () => { + const result = parseGraphUpdateProposal({ + ...makeValidProposal(), + answerMeaning: { + userSupportedMeaning: + "I'd normally avoid more risk, but for the right opportunity I might accept some.", + possibleInference: + "This may support later clarification, but the condition remains material.", + supportCategory: "conditional_preference", + resolutionGuidance: + "Identify and quantify the threshold conditions that trigger risk acceptance.", + }, + }); + + expect(result.success).toBe(true); + expect(result.proposal.answerMeaning).toMatchObject({ + supportCategory: "conditional_preference", + resolutionGuidance: + "Identify and quantify the threshold conditions that trigger risk acceptance.", + }); + }); + + it("accepts the earlier live Regression B wording variant without special aliasing", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), answerMeaning: { @@ -110,25 +133,18 @@ describe("parseGraphUpdateProposal", () => { possibleInference: "This may support later clarification, but the condition remains material.", supportCategory: "conditional_qualification", - resolutionGuidance: "may_resolve", + resolutionGuidance: "may resolve once the condition is clarified", }, }); expect(result.success).toBe(true); - expect(result.proposal.answerMeaning.supportCategory).toBe( - "conditional_tradeoff", - ); - expect(result.normalisationsApplied).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: ["answerMeaning", "supportCategory"], - change: "Converted conditional_qualification to conditional_tradeoff", - }), - ]), - ); + expect(result.proposal.answerMeaning).toMatchObject({ + supportCategory: "conditional_qualification", + resolutionGuidance: "may resolve once the condition is clarified", + }); }); - it("unknown enum values still fail", () => { + it("unknown node enum values still fail", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), addedNodes: [ @@ -152,25 +168,22 @@ describe("parseGraphUpdateProposal", () => { expect(result.success).toBe(false); }); - it("still rejects unsupported answerMeaning supportCategory labels", () => { + it("does not reject unsupported free-text answerMeaning labels at parse time", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(), answerMeaning: { - userSupportedMeaning: "Risk matters more to me.", + userSupportedMeaning: "Risk and growth are both important.", possibleInference: null, supportCategory: "constraint_preference_mix", - resolutionGuidance: "must_remain_unresolved", + resolutionGuidance: "needs more nuance", }, }); - expect(result.success).toBe(false); - expect(result.errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: ["answerMeaning", "supportCategory"], - }), - ]), - ); + expect(result.success).toBe(true); + expect(result.proposal.answerMeaning).toMatchObject({ + supportCategory: "constraint_preference_mix", + resolutionGuidance: "needs more nuance", + }); }); it("defaults missing selectedQuestion to null", () => {