Files
confidence-engine/tests/graph/update-proposal.test.js

363 lines
11 KiB
JavaScript

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 {
addedNodes: [],
updatedNodes: [
{
nodeId: "n-unknown",
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "1.9 complaints per 100 units",
reason: "The answer directly provides the normalized complaint rate.",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [],
selectedQuestion: null,
answerMeaning: {
userSupportedMeaning:
"The user directly provided the updated complaint rate.",
possibleInference: null,
supportCategory: "other",
resolutionGuidance: "may_resolve",
},
...overrides,
};
}
describe("parseGraphUpdateProposal", () => {
it("parses a valid proposal", () => {
const result = parseGraphUpdateProposal(makeValidProposal());
expect(result.success).toBe(true);
expect(result.proposal.updatedNodes).toHaveLength(1);
});
it("fails on malformed JSON", () => {
const result = parseGraphUpdateProposal("{not json");
expect(result.success).toBe(false);
});
it("fails when required update content is invalid", () => {
const result = parseGraphUpdateProposal({
updatedNodes: [{ nodeId: "n-unknown" }],
});
expect(result.success).toBe(false);
});
it("removes null array entries and logs them", () => {
const result = parseGraphUpdateProposal(
JSON.stringify({
...makeValidProposal(),
addedNodes: [null],
}),
);
expect(result.success).toBe(true);
expect(result.proposal.addedNodes).toEqual([]);
expect(result.normalisationsApplied).toEqual(
expect.arrayContaining([
expect.objectContaining({ change: "Removed null array entry" }),
]),
);
});
it("fills missing optional arrays with empty arrays", () => {
const result = parseGraphUpdateProposal({
updatedNodes: [],
});
expect(result.success).toBe(true);
expect(result.proposal.addedNodes).toEqual([]);
expect(result.proposal.addedEdges).toEqual([]);
expect(result.normalisationsApplied.length).toBeGreaterThan(0);
});
it("normalises confirmed node-kind alias and preserves IDs", () => {
const result = parseGraphUpdateProposal({
...makeValidProposal(),
addedNodes: [
{
id: "n-new",
label: "Reported update",
description: "A new reported claim",
kind: "reported_statement",
status: "supported",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
],
});
expect(result.success).toBe(true);
expect(result.proposal.addedNodes[0].kind).toBe("reported_claim");
expect(result.proposal.addedNodes[0].id).toBe("n-new");
});
it("rejects supportCategory values outside the existing enum", () => {
const result = parseGraphUpdateProposal({
...makeValidProposal(),
answerMeaning: {
userSupportedMeaning:
"I'd normally avoid more risk, but for the right opportunity I might accept some.",
possibleInference:
"This may support later clarification, but the condition remains material.",
supportCategory: "conditional_preference",
resolutionGuidance:
"Identify and quantify the threshold conditions that trigger risk acceptance.",
},
});
expect(result.success).toBe(false);
});
it("rejects the earlier live Regression B wording variant when it is outside the existing enum", () => {
const result = parseGraphUpdateProposal({
...makeValidProposal(),
answerMeaning: {
userSupportedMeaning:
"I'd normally avoid more risk, but for the right opportunity I might accept some.",
possibleInference:
"This may support later clarification, but the condition remains material.",
supportCategory: "conditional_qualification",
resolutionGuidance: "may resolve once the condition is clarified",
},
});
expect(result.success).toBe(false);
});
it("accepts valid structured answerMeaning enum values", () => {
const result = parseGraphUpdateProposal({
...makeValidProposal(),
answerMeaning: {
userSupportedMeaning:
"I'd normally avoid more risk, but for the right opportunity I might accept some.",
possibleInference:
"This may support later clarification, but the condition remains material.",
supportCategory: "conditional_tradeoff",
resolutionGuidance: "may_resolve",
},
});
expect(result.success).toBe(true);
expect(result.proposal.answerMeaning).toMatchObject({
supportCategory: "conditional_tradeoff",
resolutionGuidance: "may_resolve",
});
});
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(),
addedNodes: [
{
id: "n-new",
label: "Bad node",
description: "Bad node",
kind: "unsupported_kind",
status: "supported",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
],
});
expect(result.success).toBe(false);
});
it("rejects unsupported answerMeaning enum labels at parse time", () => {
const result = parseGraphUpdateProposal({
...makeValidProposal(),
answerMeaning: {
userSupportedMeaning: "Risk and growth are both important.",
possibleInference: null,
supportCategory: "constraint_preference_mix",
resolutionGuidance: "needs more nuance",
},
});
expect(result.success).toBe(false);
});
it("defaults missing selectedQuestion to null", () => {
const result = parseGraphUpdateProposal({
addedNodes: [],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.success).toBe(true);
expect(result.proposal.selectedQuestion).toBeNull();
});
it("defaults missing answerMeaning to null", () => {
const result = parseGraphUpdateProposal({
addedNodes: [],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
selectedQuestion: null,
});
expect(result.success).toBe(true);
expect(result.proposal.answerMeaning).toBeNull();
});
it("parses a valid selectedQuestion", () => {
const result = parseGraphUpdateProposal(
makeValidProposal({
selectedQuestion: {
nodeId: "n-follow-up",
question: "How should commercial value be defined for this decision?",
reason: "A consequential unknown remains unresolved.",
},
}),
);
expect(result.success).toBe(true);
expect(result.proposal.selectedQuestion?.nodeId).toBe("n-follow-up");
});
it("does not invent a next question field outside the contract", () => {
const result = parseGraphUpdateProposal(makeValidProposal());
expect(result.proposal.nextQuestion).toBeUndefined();
});
});