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:
@@ -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.
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
+35
-9
@@ -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(
|
) {
|
||||||
"answerMeaning.userSupportedMeaning is populated, but the proposal contains no graph mutation. answerMeaning alone does not constitute graph progress.",
|
errors.push(
|
||||||
);
|
"structuralActionRequired must be present when userSupportedMeaning is populated",
|
||||||
} else {
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
"answerMeaning.userSupportedMeaning is populated, but the proposal contains no graph mutation. answerMeaning alone does not constitute graph progress.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!meaningPopulated) {
|
||||||
errors.push("Update contains no meaningful change");
|
errors.push("Update contains no meaningful change");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user