diff --git a/lib/graph/update-proposal.js b/lib/graph/update-proposal.js index 8e462ea..95d01d5 100644 --- a/lib/graph/update-proposal.js +++ b/lib/graph/update-proposal.js @@ -62,6 +62,19 @@ function applyKnownEnumAliases(proposal, normalisationsApplied) { }); } + if (Array.isArray(proposal.addedEdges)) { + proposal.addedEdges = proposal.addedEdges.map((edge, index) => { + if (edge?.relationship === "affects") { + normalisationsApplied.push({ + path: ["addedEdges", index, "relationship"], + change: "Converted affects to other", + }); + return { ...edge, relationship: "other" }; + } + return edge; + }); + } + return proposal; } diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index cb245a7..0b22293 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -401,6 +401,35 @@ describe("applyValidatedProposal", () => { expect(result.stage).toBe("proposal_compatibility"); }); + it("accepts a proposal edge normalised from affects to canonical other before application", () => { + const { graph, proposal } = makeApplicationFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + ...proposal, + addedEdges: [ + makeEdge({ + id: "e-other-edge", + fromNodeId: "n-complaint-count", + toNodeId: "n-quality-deterioration", + relationship: "other", + confidence: "medium", + description: + "Canonical generic relationship after proposal-boundary normalisation.", + }), + ], + }, + }); + + expect(result.success).toBe(true); + expect( + result.updatedSituationGraph.edges.some( + (edge) => edge.id === "e-other-edge" && edge.relationship === "other", + ), + ).toBe(true); + }); + it("rejects duplicate IDs", () => { const { graph, proposal, ids } = makeApplicationFixture(); diff --git a/tests/graph/update-proposal.test.js b/tests/graph/update-proposal.test.js index 82d571c..b8bb8cc 100644 --- a/tests/graph/update-proposal.test.js +++ b/tests/graph/update-proposal.test.js @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseGraphUpdateProposal } from "@/lib/graph/update-proposal.js"; +import { applyGraphUpdate } from "@/lib/graph/utils.js"; +import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; function makeValidProposal(overrides = {}) { return { @@ -144,6 +146,125 @@ describe("parseGraphUpdateProposal", () => { }); }); + it("normalises the observed external relationship wording affects to canonical other at the proposal boundary", () => { + const result = parseGraphUpdateProposal({ + ...makeValidProposal(), + addedEdges: [ + { + id: "e-affects", + fromNodeId: "n-driver", + toNodeId: "n-unknown", + relationship: "affects", + confidence: "medium", + description: "Model-style relationship wording from live output.", + }, + ], + }); + + expect(result.success).toBe(true); + expect(result.proposal.addedEdges[0].relationship).toBe("other"); + expect(result.normalisationsApplied).toEqual( + expect.arrayContaining([ + expect.objectContaining({ change: "Converted affects to other" }), + ]), + ); + }); + + it("leaves already-valid canonical relationship values unchanged", () => { + const canonicalRelationships = [ + "supports", + "weakens", + "contradicts", + "depends_on", + "causes", + "may_cause", + "measures", + "compares_with", + "updates", + "other", + ]; + + for (const relationship of canonicalRelationships) { + const result = parseGraphUpdateProposal({ + ...makeValidProposal(), + addedEdges: [ + { + id: `e-${relationship}`, + fromNodeId: "n-driver", + toNodeId: "n-unknown", + relationship, + confidence: "medium", + description: `Canonical relationship ${relationship}`, + }, + ], + }); + + expect(result.success).toBe(true); + expect(result.proposal.addedEdges[0].relationship).toBe(relationship); + } + }); + + it("still rejects unsupported arbitrary relationship values", () => { + const result = parseGraphUpdateProposal({ + ...makeValidProposal(), + addedEdges: [ + { + id: "e-unsupported", + fromNodeId: "n-driver", + toNodeId: "n-unknown", + relationship: "influences_decisively", + confidence: "medium", + description: "Unsupported relationship wording.", + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("applies only the canonical internal relationship after normalisation", () => { + const parsed = parseGraphUpdateProposal({ + ...makeValidProposal({ + updatedNodes: [], + resolvedUnknownNodeIds: [], + }), + addedEdges: [ + { + id: "e-affects", + fromNodeId: "n-driver", + toNodeId: "n-unknown", + relationship: "affects", + confidence: "medium", + description: "Model-style relationship wording from live output.", + }, + ], + }); + + expect(parsed.success).toBe(true); + + const graph = makeGraph({ + centralStatement: "Relationship application fixture", + nodes: [ + makeNode({ id: "n-driver", label: "Driver", status: "known" }), + makeNode({ id: "n-unknown", label: "Unknown" }), + ], + edges: [], + currentSummary: "Initial summary", + }); + + const applied = applyGraphUpdate(graph, parsed.proposal); + + expect(applied.success).toBe(true); + expect(applied.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ relationship: "other" }), + ]), + ); + expect(applied.edges.some((edge) => edge.relationship === "affects")).toBe( + false, + ); + }); + it("unknown node enum values still fail", () => { const result = parseGraphUpdateProposal({ ...makeValidProposal(),