feat: apply validated graph update proposals
This commit is contained in:
@@ -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")]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user