refine answerMeaning contract around user-supported meaning

This commit is contained in:
2026-08-09 08:50:37 +01:00
parent 869afee1ab
commit 7965375aff
8 changed files with 208 additions and 57 deletions
+66 -2
View File
@@ -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;
}
+3 -3
View File
@@ -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.
+2 -2
View File
@@ -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();
-18
View File
@@ -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;
}
+92 -3
View File
@@ -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();
+4 -1
View File
@@ -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",
);
});
});
+2 -2
View File
@@ -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);
+39 -26
View File
@@ -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", () => {