diff --git a/lib/graph/prompt-builder.js b/lib/graph/prompt-builder.js index fe12352..b8896ae 100644 --- a/lib/graph/prompt-builder.js +++ b/lib/graph/prompt-builder.js @@ -80,6 +80,7 @@ The JSON object must contain exactly these top-level fields: - affectedNodeIds - selectedQuestion - answerMeaning +- structuralActionRequired ## Required Shapes - 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 - answerMeaning: either null or an object using these exact keys: userSupportedMeaning, possibleInference, supportCategory, resolutionGuidance +- structuralActionRequired: boolean (required when userSupportedMeaning is populated) ## Proposal Rules 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. 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 - 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. diff --git a/lib/graph/schema.js b/lib/graph/schema.js index 91d3131..b2bf340 100644 --- a/lib/graph/schema.js +++ b/lib/graph/schema.js @@ -190,6 +190,7 @@ export const graphUpdateSchema = z.object({ affectedNodeIds: z.array(z.string()).default([]), selectedQuestion: selectedQuestionSchema.nullable().default(null), answerMeaning: answerMeaningSchema.nullable().default(null), + structuralActionRequired: z.boolean().nullable().optional(), }); /** @typedef {z.infer} GraphUpdate */ diff --git a/lib/graph/utils.js b/lib/graph/utils.js index 5da637a..7aebb83 100644 --- a/lib/graph/utils.js +++ b/lib/graph/utils.js @@ -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( (u) => u.previousStatus !== null && u.newStatus !== u.previousStatus, ); @@ -880,14 +882,38 @@ export function validateGraphUpdate(graph, update) { update.addedEdges.length > 0 || update.removedEdgeIds.length > 0; - if (!hasMeaningfulChange) { - // Specific diagnostic for the semantic-only no-op case: populated userSupportedMeaning with zero structural mutation. - // The validator does NOT determine whether meaning is "new" or "consequential" — it only observes that meaning exists without structural expression. - if (update.answerMeaning?.userSupportedMeaning) { - errors.push( - "answerMeaning.userSupportedMeaning is populated, but the proposal contains no graph mutation. answerMeaning alone does not constitute graph progress.", - ); - } else { + // Missing/null transition rule: must be present when userSupportedMeaning is populated + if ( + (update.structuralActionRequired === null || update.structuralActionRequired === undefined) && + 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( + "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"); } }