feat: apply validated graph update proposals

This commit is contained in:
2026-08-02 08:24:56 +01:00
parent f3cdfce0b0
commit cb77f955ed
4 changed files with 869 additions and 0 deletions
+404
View File
@@ -0,0 +1,404 @@
import { describe, expect, it } from "vitest";
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
import { validateGraphReferences } from "@/lib/graph/utils.js";
function makeApplicationFixture() {
const complaintRateUnknown = makeNode({
id: "n-complaint-rate-unknown",
label: "Complaint rate",
description: "Need the complaint rate per 100 units",
kind: "unknown",
status: "unknown",
confidence: "high",
affects: ["n-quality-deterioration"],
});
const staffingUnknown = makeNode({
id: "n-staffing-unknown",
label: "Staffing change",
description: "Need to know if staffing changed",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const qualityDeterioration = makeNode({
id: "n-quality-deterioration",
label: "Quality deterioration conclusion",
description: "Conclusion that quality deteriorated",
kind: "conclusion",
status: "supported",
confidence: "medium",
dependsOn: ["n-complaint-rate-unknown"],
});
const complaintCount = makeNode({
id: "n-complaint-count",
label: "Complaint count observation",
description: "Complaint count increased",
kind: "observation",
status: "supported",
confidence: "high",
value: 135,
unit: "count",
});
const productionCount = makeNode({
id: "n-production-count",
label: "Production count observation",
description: "Production increased",
kind: "observation",
status: "supported",
confidence: "high",
value: 7100,
unit: "units",
});
const graph = makeGraph({
centralStatement: "Complaints rose while production also rose.",
nodes: [
complaintRateUnknown,
staffingUnknown,
qualityDeterioration,
complaintCount,
productionCount,
],
edges: [
makeEdge({
id: "e-quality-depends-rate",
fromNodeId: complaintRateUnknown.id,
toNodeId: qualityDeterioration.id,
relationship: "supports",
confidence: "medium",
description: "The rate informs the quality conclusion",
}),
],
activeUnknownNodeId: complaintRateUnknown.id,
resolvedNodeIds: [],
currentSummary: "Initial summary",
});
const proposal = {
addedNodes: [],
updatedNodes: [
{
nodeId: complaintRateUnknown.id,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: "2.0 complaints per 100 units",
newValue: "1.9 complaints per 100 units",
reason: "The answer provides the updated normalized complaint rate.",
},
{
nodeId: qualityDeterioration.id,
previousStatus: "supported",
newStatus: "weakened",
previousValue: null,
newValue: null,
reason: "The improved rate weakens the deterioration conclusion.",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [complaintRateUnknown.id],
affectedNodeIds: [qualityDeterioration.id],
};
return {
graph,
proposal,
ids: {
complaintRateUnknown: complaintRateUnknown.id,
staffingUnknown: staffingUnknown.id,
qualityDeterioration: qualityDeterioration.id,
complaintCount: complaintCount.id,
productionCount: productionCount.id,
},
};
}
describe("applyValidatedProposal", () => {
it("applies a valid proposal successfully", () => {
const { graph, proposal, ids } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal,
});
expect(result).toMatchObject({
success: true,
graphUpdate: proposal,
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
previousActiveUnknownNodeId: ids.complaintRateUnknown,
newActiveUnknownNodeId: ids.staffingUnknown,
});
expect(
result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.complaintRateUnknown,
)?.status,
).toBe("resolved");
expect(
result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.qualityDeterioration,
)?.status,
).toBe("weakened");
});
it("rejects an invalid graph before application", () => {
const { graph, proposal } = makeApplicationFixture();
graph.nodes[0].dependsOn.push("missing-node");
const original = JSON.parse(JSON.stringify(graph));
const result = applyValidatedProposal({
situationGraph: graph,
proposal,
});
expect(result.success).toBe(false);
expect(result.stage).toBe("graph_validation");
expect(graph).toEqual(original);
});
it("rejects updates referencing nonexistent nodes", () => {
const { graph, proposal } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
...proposal,
updatedNodes: [
...proposal.updatedNodes,
{
nodeId: "ghost-node",
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: null,
reason: "Invalid reference",
},
],
},
});
expect(result).toMatchObject({
success: false,
stage: "proposal_compatibility",
});
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining(
'Cannot update non-existent node: "ghost-node"',
),
]),
);
});
it("rejects added edges with invalid references", () => {
const { graph, proposal } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
...proposal,
addedEdges: [
makeEdge({
id: "e-invalid",
fromNodeId: "missing-node",
toNodeId: "n-quality-deterioration",
relationship: "supports",
confidence: "medium",
description: "Invalid edge",
}),
],
},
});
expect(result.success).toBe(false);
expect(result.stage).toBe("proposal_compatibility");
});
it("rejects duplicate IDs", () => {
const { graph, proposal, ids } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
...proposal,
addedNodes: [
makeNode({
id: ids.qualityDeterioration,
label: "Duplicate",
description: "Duplicate node id",
}),
],
},
});
expect(result.success).toBe(false);
expect(result.stage).toBe("proposal_compatibility");
expect(result.errors.join(" ")).toContain("duplicate node ID");
});
it("preserves unrelated nodes byte-for-byte", () => {
const { graph, proposal, ids } = makeApplicationFixture();
const originalComplaintCount = JSON.stringify(
graph.nodes.find((node) => node.id === ids.complaintCount),
);
const originalProductionCount = JSON.stringify(
graph.nodes.find((node) => node.id === ids.productionCount),
);
const result = applyValidatedProposal({
situationGraph: graph,
proposal,
});
expect(result.success).toBe(true);
expect(
JSON.stringify(
result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.complaintCount,
),
),
).toBe(originalComplaintCount);
expect(
JSON.stringify(
result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.productionCount,
),
),
).toBe(originalProductionCount);
});
it("adds resolved unknowns to resolvedNodeIds", () => {
const { graph, proposal, ids } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal,
});
expect(result.success).toBe(true);
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
ids.complaintRateUnknown,
);
});
it("keeps the active unknown when it remains unresolved", () => {
const { graph, ids } = makeApplicationFixture();
const proposal = {
addedNodes: [],
updatedNodes: [
{
nodeId: ids.qualityDeterioration,
previousStatus: "supported",
newStatus: "weakened",
previousValue: null,
newValue: null,
reason: "Only the conclusion changes",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [ids.qualityDeterioration],
};
const result = applyValidatedProposal({
situationGraph: graph,
proposal,
});
expect(result.success).toBe(true);
expect(result.previousActiveUnknownNodeId).toBe(ids.complaintRateUnknown);
expect(result.newActiveUnknownNodeId).toBe(ids.complaintRateUnknown);
});
it("reports affected node ids", () => {
const { graph, proposal, ids } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal,
});
expect(result.success).toBe(true);
expect(result.affectedNodeIds).toEqual(
expect.arrayContaining([
ids.complaintRateUnknown,
ids.qualityDeterioration,
]),
);
});
it("revalidates the completed graph references", () => {
const { graph, proposal } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal,
});
expect(result.success).toBe(true);
expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({
valid: true,
errors: [],
});
});
it("is atomic on failure", () => {
const { graph, proposal } = makeApplicationFixture();
const originalGraph = JSON.parse(JSON.stringify(graph));
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
...proposal,
addedEdges: [
makeEdge({
id: "e-bad",
fromNodeId: "missing-node",
toNodeId: "n-quality-deterioration",
relationship: "supports",
confidence: "medium",
description: "Invalid edge",
}),
],
},
});
expect(result.success).toBe(false);
expect(graph).toEqual(originalGraph);
});
it("rejects a proposal with no meaningful change", () => {
const { graph } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
addedNodes: [],
updatedNodes: [
{
nodeId: "n-quality-deterioration",
previousStatus: null,
newStatus: null,
previousValue: null,
newValue: null,
reason: "No change",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
},
});
expect(result).toMatchObject({
success: false,
stage: "proposal_compatibility",
});
expect(result.errors).toEqual(
expect.arrayContaining([expect.stringContaining("no meaningful change")]),
);
});
});