From 6adcd817e1c0730fe8344358aa5382ab4ebae998 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 11 Aug 2026 12:32:03 +0100 Subject: [PATCH] reasoning: require structural progress for supported meaning --- lib/graph/prompt-builder.js | 2 +- lib/graph/utils.js | 10 +- tests/graph/prompt-builder.test.js | 49 ++++++++ tests/graph/utils.test.js | 185 +++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 2 deletions(-) diff --git a/lib/graph/prompt-builder.js b/lib/graph/prompt-builder.js index 7c41a3b..8007012 100644 --- a/lib/graph/prompt-builder.js +++ b/lib/graph/prompt-builder.js @@ -92,7 +92,7 @@ The JSON object must contain exactly these top-level fields: 3. Reference existing node IDs when updating an existing concept. 4. Use addedNodes only for genuinely new concepts. 5. Resolve the answered unknown first when the answer supports it. -6. Then inspect the answer for newly introduced consequential uncertainty. +6. If answerMeaning.userSupportedMeaning contains consequential information or unresolved uncertainty that is not already represented in the graph, you MUST express its effect through structural mutation. This may be an update/refinement of existing structure, resolution of an existing unknown, a genuinely new unknown, or a justified relationship. answerMeaning alone is not sufficient for a successful proposal. 7. Add new unknown nodes only when the answer introduces a new decision, claim, object, measure, dependency, or unresolved term directly relevant to the case. 8. Add at most 3 new unknown nodes. 9. Every new unknown must be directly traceable to the user's answer and its description must state why that uncertainty matters. diff --git a/lib/graph/utils.js b/lib/graph/utils.js index e84a2bd..5da637a 100644 --- a/lib/graph/utils.js +++ b/lib/graph/utils.js @@ -881,7 +881,15 @@ export function validateGraphUpdate(graph, update) { update.removedEdgeIds.length > 0; if (!hasMeaningfulChange) { - errors.push("Update contains no meaningful change"); + // 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 { + errors.push("Update contains no meaningful change"); + } } // Reject oversized input diff --git a/tests/graph/prompt-builder.test.js b/tests/graph/prompt-builder.test.js index e308245..43acba7 100644 --- a/tests/graph/prompt-builder.test.js +++ b/tests/graph/prompt-builder.test.js @@ -129,3 +129,52 @@ describe("buildGraphUpdatePrompt", () => { ); }); }); + +// ── Semantic-to-mutation contract (57J.39) ────────────── + +describe("buildGraphUpdatePrompt — semantic-to-mutation MUST rule", () => { + it("contains the explicit structural-materialization MUST rule", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + expect(prompt).toContain("MUST express its effect through structural mutation"); + }); + + it("rule permits update/refine of existing structure", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + expect(prompt).toContain("update/refinement of existing structure"); + }); + + it("rule permits resolving an existing unknown", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + expect(prompt).toContain("resolution of an existing unknown"); + }); + + it("rule permits genuinely new unknown when needed", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + expect(prompt).toContain("a genuinely new unknown"); + }); + + it("rule explicitly states answerMeaning alone is not sufficient", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + expect(prompt).toContain("answerMeaning alone is not sufficient"); + }); + + it("rule does NOT force adding a new node", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + // The rule should be silent about forcing new nodes — this is preserved by existing rule #7. + // Verify the MUST rule exists but doesn't contain "must add a new node" or similar. + const mustRuleMatch = prompt.match( + /6\..*?(?=\n7\.)/s, + ); + expect(mustRuleMatch).not.toBe(null); + expect(mustRuleMatch[0]).not.toContain("must add a new node"); + }); + + it("does not imply possibleInference alone triggers mutation", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + // The rule must reference userSupportedMeaning specifically, not possibleInference as a trigger. + const mustRuleMatch = prompt.match( + /6\..*?(?=\n7\.)/s, + ); + expect(mustRuleMatch[0]).toContain("userSupportedMeaning"); + }); +}); diff --git a/tests/graph/utils.test.js b/tests/graph/utils.test.js index 3de4b30..f453aad 100644 --- a/tests/graph/utils.test.js +++ b/tests/graph/utils.test.js @@ -1155,3 +1155,188 @@ describe("update lifecycle integration", () => { expect(graph.nodes[0].status).toBe("unknown"); // unchanged }); }); + +// ── Semantic-to-mutation contract (57J.39) ────────────── + +describe("semantic-to-mutation contract", () => { + const baseUpdate = { + addedNodes: [], + updatedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: [], + }; + + // Test 1 — semantic-only no-op + it("REJECTS with specific semantic-only structural-progress error when userSupportedMeaning populated and zero structural mutation", () => { + const graph = makeTestGraph(); + const update = { + ...baseUpdate, + answerMeaning: { + userSupportedMeaning: "The user states that cost reduction is a primary driver.", + possibleInference: null, + }, + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(false); + expect( + result.errors.some((e) => e.includes("userSupportedMeaning") && e.includes("mutation")), + ).toBe(true); + // Should NOT contain only the generic no-op message without the semantic-specific variant + expect(result.errors.some((e) => e === "Update contains no meaningful change")).toBe(false); + }); + + // Test 2 — ordinary no-op (answerMeaning null) + it("REJECTS with existing 'no meaningful change' when answerMeaning is null and zero structural mutation", () => { + const graph = makeTestGraph(); + const update = { + ...baseUpdate, + answerMeaning: null, + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true); + }); + + // Test 3 — possibleInference only + it("does NOT trigger the new userSupportedMeaning-specific error when only possibleInference is populated", () => { + const graph = makeTestGraph(); + const update = { + ...baseUpdate, + answerMeaning: { + userSupportedMeaning: null, + possibleInference: "Cost reduction could be achieved through staff consolidation.", + }, + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(false); + // Must NOT have the semantic-specific error (userSupportedMeaning is not populated) + expect( + result.errors.some((e) => e.includes("userSupportedMeaning") && e.includes("mutation")), + ).toBe(false); + // Generic no-op still applies + expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true); + }); + + // Test 4 — update existing structure (valid structural progress) + it("ACCEPTS past new guard when userSupportedMeaning populated AND valid existing-node status change", () => { + const graph = makeTestGraph(); + const update = { + ...baseUpdate, + answerMeaning: { + userSupportedMeaning: "The user confirms risk is a hard constraint.", + possibleInference: null, + }, + updatedNodes: [ + { + nodeId: "n4", + previousStatus: "unknown", + newStatus: "known", + reason: "Confirmed by user answer", + }, + ], + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + }); + + // Test 5 — resolve existing unknown (counts as structural progress) + it("counts as structural progress when userSupportedMeaning populated AND valid resolution of existing unknown", () => { + const graph = makeTestGraph(); + const update = { + ...baseUpdate, + answerMeaning: { + userSupportedMeaning: "The user provides criteria for acceptable opportunity.", + possibleInference: null, + }, + updatedNodes: [ + { + nodeId: "n4", + previousStatus: "unknown", + newStatus: "resolved", + reason: "Threshold defined by user", + }, + ], + resolvedUnknownNodeIds: ["n4"], + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + }); + + // Test 6 — add new structure (counts as structural progress) + it("counts as structural progress when userSupportedMeaning populated AND valid added unknown", () => { + const graph = makeTestGraph(); + const update = { + ...baseUpdate, + answerMeaning: { + userSupportedMeaning: "The user needs evidence for both savings realism and retention impact.", + possibleInference: null, + }, + addedNodes: [makeNode({ id: "n-new-unknown", label: "New unknown" })], + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + }); + + // Test 7 — duplicate avoidance preserved + it("still rejects duplicate node IDs even with populated userSupportedMeaning", () => { + const graph = makeTestGraph(); + const existingNode = graph.nodes[0]; + const update = { + ...baseUpdate, + answerMeaning: { + userSupportedMeaning: "There is a new constraint the user identified.", + possibleInference: null, + }, + addedNodes: [existingNode], // Duplicate ID — should still be rejected + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes("duplicate ID"))).toBe(true); + }); + + // Test 8 — userSupportedMeaning with meaningful value change (no status change) + it("counts as structural progress when userSupportedMeaning populated AND valid existing-node value change", () => { + const graph = makeTestGraph(); + const update = { + ...baseUpdate, + answerMeaning: { + userSupportedMeaning: "The user clarified the constraint is absolute.", + possibleInference: null, + }, + updatedNodes: [ + { + nodeId: "n4", + previousStatus: null, + newStatus: null, + previousValue: null, + newValue: "absolute_constraint", + reason: "Clarified by user answer", + }, + ], + }; + + const result = validateGraphUpdate(graph, update); + + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + }); +});