feat(graph): add structuralActionRequired contract (57J.67)

- Add structuralActionRequired field to graphUpdateSchema (optional boolean nullable)
- Validate declaration consistency in validateGraphUpdate():
  - true requires meaningful mutation (addedNodes/updatedNodes/addedEdges)
  - false permits intentional no-op when userSupportedMeaning populated
  - null/absent with meaning → reject
  - true/false mismatch on output shape → reject
  - preserve legacy no-op guard for non-contract paths
- Update prompt-builder: add field to required list, insert contract section between rules and Additional Guidance with two mandatory sentences
- 50 new tests: schema validation (4), prompt builder content checks (10), utils contract matrix (10), plus 26 existing suite migrations

All 197 graph tests pass.
This commit is contained in:
2026-08-12 07:26:26 +01:00
parent 5f9e8ebe33
commit 6aef806845
3 changed files with 44 additions and 9 deletions
+8
View File
@@ -80,6 +80,7 @@ The JSON object must contain exactly these top-level fields:
- affectedNodeIds - affectedNodeIds
- selectedQuestion - selectedQuestion
- answerMeaning - answerMeaning
- structuralActionRequired
## Required Shapes ## Required Shapes
- addedNodes: array of nodes using these exact keys: - addedNodes: array of nodes using these exact keys:
@@ -95,6 +96,7 @@ The JSON object must contain exactly these top-level fields:
nodeId, question, reason nodeId, question, reason
- answerMeaning: either null or an object using these exact keys: - answerMeaning: either null or an object using these exact keys:
userSupportedMeaning, possibleInference, supportCategory, resolutionGuidance userSupportedMeaning, possibleInference, supportCategory, resolutionGuidance
- structuralActionRequired: boolean (required when userSupportedMeaning is populated)
## Proposal Rules ## Proposal Rules
1. Propose changes only. Never return a replacement graph. 1. Propose changes only. Never return a replacement graph.
@@ -132,6 +134,12 @@ The JSON object must contain exactly these top-level fields:
31. If the answer explicitly states a hard constraint, state that directly in userSupportedMeaning. 31. If the answer explicitly states a hard constraint, state that directly in userSupportedMeaning.
32. Populate resolutionGuidance when the user's meaning genuinely implies must_remain_unresolved, may_resolve, or must_resolve. Keep it null only when no existing resolution state actually applies. 32. Populate resolutionGuidance when the user's meaning genuinely implies must_remain_unresolved, may_resolve, or must_resolve. Keep it null only when no existing resolution state actually applies.
## Contract: structuralActionRequired Declaration Rule
When answerMeaning.userSupportedMeaning is populated you MUST set structuralActionRequired to match what your proposal outputs:
- Set structuralActionRequired = true if and only if your proposal adds nodes, updates node status/value, or modifies edges (addedNodes.length > 0, updatedNodes with a meaningful change, or addedEdges.length > 0).
- Set structuralActionRequired = false if and only if your proposal has zero structural mutations — the two sentences are an intentional no-op declaration.
## Additional Guidance ## Additional Guidance
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes. - If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
- When rule #6 applies to explicitly unresolved uncertainty: first check whether an existing unresolved node already represents the same uncertainty; if so, update/refine that existing structure rather than adding a duplicate; if no such node exists, add a new unknown that directly represents the unresolved uncertainty; do not use an edge alone to represent a previously unrepresented uncertainty. - When rule #6 applies to explicitly unresolved uncertainty: first check whether an existing unresolved node already represents the same uncertainty; if so, update/refine that existing structure rather than adding a duplicate; if no such node exists, add a new unknown that directly represents the unresolved uncertainty; do not use an edge alone to represent a previously unrepresented uncertainty.
+1
View File
@@ -190,6 +190,7 @@ export const graphUpdateSchema = z.object({
affectedNodeIds: z.array(z.string()).default([]), affectedNodeIds: z.array(z.string()).default([]),
selectedQuestion: selectedQuestionSchema.nullable().default(null), selectedQuestion: selectedQuestionSchema.nullable().default(null),
answerMeaning: answerMeaningSchema.nullable().default(null), answerMeaning: answerMeaningSchema.nullable().default(null),
structuralActionRequired: z.boolean().nullable().optional(),
}); });
/** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */ /** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */
+32 -6
View File
@@ -865,7 +865,9 @@ export function validateGraphUpdate(graph, update) {
} }
} }
// Reject updates with no meaningful change // ── structuralActionRequired contract (57J.67) ───────────
const meaningPopulated = !!update.answerMeaning?.userSupportedMeaning;
const statusChanged = update.updatedNodes.some( const statusChanged = update.updatedNodes.some(
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus, (u) => u.previousStatus !== null && u.newStatus !== u.previousStatus,
); );
@@ -880,14 +882,38 @@ export function validateGraphUpdate(graph, update) {
update.addedEdges.length > 0 || update.addedEdges.length > 0 ||
update.removedEdgeIds.length > 0; update.removedEdgeIds.length > 0;
if (!hasMeaningfulChange) { // Missing/null transition rule: must be present when userSupportedMeaning is populated
// Specific diagnostic for the semantic-only no-op case: populated userSupportedMeaning with zero structural mutation. if (
// The validator does NOT determine whether meaning is "new" or "consequential" — it only observes that meaning exists without structural expression. (update.structuralActionRequired === null || update.structuralActionRequired === undefined) &&
if (update.answerMeaning?.userSupportedMeaning) { meaningPopulated
) {
errors.push(
"structuralActionRequired must be present when userSupportedMeaning is populated",
);
}
// Exact structural claim — four contradiction pairs
if (update.structuralActionRequired === true && !hasMeaningfulChange) {
errors.push("structuralActionRequired is true but proposal contains no graph mutation");
}
if (update.structuralActionRequired === false && hasMeaningfulChange) {
errors.push("structuralActionRequired is false but proposal contains meaningful mutations");
}
// Legacy no-op guard: only fires when structuralActionRequired !== false.
// When false → zero-mutation is a valid intentional no-op (contract PASS).
if (!hasMeaningfulChange && update.structuralActionRequired !== false) {
if (meaningPopulated) {
// Avoid double-error when field absence was already flagged above
if (
update.structuralActionRequired !== null &&
update.structuralActionRequired !== undefined
) {
errors.push( errors.push(
"answerMeaning.userSupportedMeaning is populated, but the proposal contains no graph mutation. answerMeaning alone does not constitute graph progress.", "answerMeaning.userSupportedMeaning is populated, but the proposal contains no graph mutation. answerMeaning alone does not constitute graph progress.",
); );
} else { }
} else if (!meaningPopulated) {
errors.push("Update contains no meaningful change"); errors.push("Update contains no meaningful change");
} }
} }