feat: prioritise follow-up questions by information value
This commit is contained in:
@@ -546,7 +546,10 @@ describe("applyValidatedProposal", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
||||
expect(result.selectedQuestion).toEqual(proposal.selectedQuestion);
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||
expect(result.selectedQuestion?.question).toContain(
|
||||
"Commercial value definition",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects more than 3 added unknowns", () => {
|
||||
@@ -699,4 +702,92 @@ describe("applyValidatedProposal", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId);
|
||||
});
|
||||
|
||||
it("replaces downstream pricing question with higher-value commercial-value question", () => {
|
||||
const { graph, ids } = makeApplicationFixture();
|
||||
|
||||
const proposal = {
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-commercial-value",
|
||||
label: "Commercial value definition",
|
||||
description:
|
||||
"Need commercial value definition because the decision depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-pricing",
|
||||
label: "Target price point",
|
||||
description:
|
||||
"Need a price point because revenue assumptions depend on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
dependsOn: ["n-commercial-value"],
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-build-decision",
|
||||
label: "Build Confidence Engine decision",
|
||||
description: "Decision introduced by the answer.",
|
||||
kind: "state",
|
||||
status: "supported",
|
||||
confidence: "medium",
|
||||
}),
|
||||
],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.complaintRateUnknown,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "Decision whether to build Confidence Engine",
|
||||
reason: "The answer resolves the original context unknown.",
|
||||
},
|
||||
],
|
||||
addedEdges: [
|
||||
makeEdge({
|
||||
id: "e-build-commercial-value",
|
||||
fromNodeId: "n-build-decision",
|
||||
toNodeId: "n-commercial-value",
|
||||
relationship: "depends_on",
|
||||
confidence: "medium",
|
||||
description: "The decision depends on defining commercial value.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-commercial-value-pricing",
|
||||
fromNodeId: "n-commercial-value",
|
||||
toNodeId: "n-pricing",
|
||||
relationship: "depends_on",
|
||||
confidence: "medium",
|
||||
description: "Pricing depends on commercial value definition.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e-build-pricing",
|
||||
fromNodeId: "n-build-decision",
|
||||
toNodeId: "n-pricing",
|
||||
relationship: "depends_on",
|
||||
confidence: "low",
|
||||
description: "The decision also references pricing assumptions.",
|
||||
}),
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-pricing",
|
||||
question: "What is the target price point?",
|
||||
reason: "Model chose a downstream leaf.",
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||
"price",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -582,14 +582,89 @@ describe("lib/graph/orchestrator startCase", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selectedQuestion).toEqual({
|
||||
nodeId: "n-commercial-value",
|
||||
question: "How should commercial value be defined for this decision?",
|
||||
reason: "Consequential unresolved uncertainty remains.",
|
||||
});
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
||||
});
|
||||
|
||||
it("deterministically prioritises customer value over pricing follow-up", async () => {
|
||||
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||
const provider = {
|
||||
generateReconstruction: vi.fn().mockResolvedValue(
|
||||
makeProposal({
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "n-value",
|
||||
label: "Customer value",
|
||||
description:
|
||||
"Need customer value because purchase decisions depend on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-price",
|
||||
label: "Target price point",
|
||||
description:
|
||||
"Need a target price point because revenue assumptions depend on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
dependsOn: ["n-value"],
|
||||
}),
|
||||
makeNode({
|
||||
id: "n-decision",
|
||||
label: "Build Confidence Engine decision",
|
||||
description: "Decision introduced by the answer.",
|
||||
kind: "state",
|
||||
status: "supported",
|
||||
confidence: "medium",
|
||||
}),
|
||||
],
|
||||
addedEdges: [
|
||||
{
|
||||
id: "e-decision-value",
|
||||
fromNodeId: "n-decision",
|
||||
toNodeId: "n-value",
|
||||
relationship: "depends_on",
|
||||
confidence: "medium",
|
||||
description: "The decision depends on customer value.",
|
||||
},
|
||||
{
|
||||
id: "e-value-price",
|
||||
fromNodeId: "n-value",
|
||||
toNodeId: "n-price",
|
||||
relationship: "depends_on",
|
||||
confidence: "medium",
|
||||
description: "Pricing depends on customer value.",
|
||||
},
|
||||
{
|
||||
id: "e-decision-price",
|
||||
fromNodeId: "n-decision",
|
||||
toNodeId: "n-price",
|
||||
relationship: "depends_on",
|
||||
confidence: "low",
|
||||
description: "The decision references pricing assumptions.",
|
||||
},
|
||||
],
|
||||
selectedQuestion: {
|
||||
nodeId: "n-price",
|
||||
question: "What is the price point?",
|
||||
reason: "Model chose pricing.",
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const result = await updateCase(makeUpdateRequest(), {
|
||||
provider,
|
||||
config: MOCK_CONFIG,
|
||||
applyProposal: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.selectedQuestion?.nodeId).toBe("n-value");
|
||||
});
|
||||
|
||||
it("defaults to proposal-only mode", async () => {
|
||||
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||
const applyValidatedProposal = vi.fn();
|
||||
|
||||
@@ -107,5 +107,8 @@ describe("buildGraphUpdatePrompt", () => {
|
||||
expect(prompt).toContain(
|
||||
"selectedQuestion.question must be one narrow non-compound question",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"the engine will deterministically choose final priority after validation",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+413
-148
@@ -3,6 +3,7 @@ import {
|
||||
validateGraphReferences,
|
||||
detectDuplicateNodeIds,
|
||||
detectDuplicateEdges,
|
||||
scoreUnknownCandidate,
|
||||
findDependentNodes,
|
||||
findAffectedNodes,
|
||||
resolveUnknownNode,
|
||||
@@ -24,12 +25,22 @@ function makeTestGraph() {
|
||||
// n2 depends on n1; n3 depends on n2 (transitive depends on n1)
|
||||
n2.dependsOn.push(n1.id);
|
||||
n3.dependsOn.push(n2.id);
|
||||
|
||||
|
||||
// n4 is an unknown not depended on
|
||||
// n5 is an unknown depended upon by n3 indirectly
|
||||
|
||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "depends_on" });
|
||||
const e2 = makeEdge({ id: "e2", fromNodeId: n3.id, toNodeId: n1.id, relationship: "supports" });
|
||||
const e1 = makeEdge({
|
||||
id: "e1",
|
||||
fromNodeId: n1.id,
|
||||
toNodeId: n2.id,
|
||||
relationship: "depends_on",
|
||||
});
|
||||
const e2 = makeEdge({
|
||||
id: "e2",
|
||||
fromNodeId: n3.id,
|
||||
toNodeId: n1.id,
|
||||
relationship: "supports",
|
||||
});
|
||||
|
||||
return makeGraph({
|
||||
centralStatement: "Test graph",
|
||||
@@ -55,7 +66,9 @@ describe("validateGraphReferences", () => {
|
||||
graph.nodes[0].parentId = "nonexistent-parent";
|
||||
const result = validateGraphReferences(graph);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("nonexistent-parent"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("nonexistent-parent"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects invalid childIds reference", () => {
|
||||
@@ -84,7 +97,7 @@ describe("validateGraphReferences", () => {
|
||||
graph.edges[0].fromNodeId = "ghost-node";
|
||||
const result = validateGraphReferences(graph);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects edge referencing non-existent toNodeId", () => {
|
||||
@@ -98,7 +111,7 @@ describe("validateGraphReferences", () => {
|
||||
const graph = makeTestGraph();
|
||||
graph.nodes[0].parentId = "missing";
|
||||
graph.nodes[1].parentId = "also-missing";
|
||||
|
||||
|
||||
const result = validateGraphReferences(graph);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBe(2);
|
||||
@@ -154,9 +167,19 @@ describe("detectDuplicateEdges", () => {
|
||||
it("detects duplicate edge (same from, to, relationship)", () => {
|
||||
const n1 = makeNode({ id: "n1", label: "A" });
|
||||
const n2 = makeNode({ id: "n2", label: "B" });
|
||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
||||
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
||||
|
||||
const e1 = makeEdge({
|
||||
id: "e1",
|
||||
fromNodeId: n1.id,
|
||||
toNodeId: n2.id,
|
||||
relationship: "supports",
|
||||
});
|
||||
const e2 = makeEdge({
|
||||
id: "e2",
|
||||
fromNodeId: n1.id,
|
||||
toNodeId: n2.id,
|
||||
relationship: "supports",
|
||||
});
|
||||
|
||||
const dups = detectDuplicateEdges([e1, e2]);
|
||||
expect(dups.length).toBe(1);
|
||||
});
|
||||
@@ -164,9 +187,19 @@ describe("detectDuplicateEdges", () => {
|
||||
it("allows same nodes with different relationship types", () => {
|
||||
const n1 = makeNode({ id: "n1", label: "A" });
|
||||
const n2 = makeNode({ id: "n2", label: "B" });
|
||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
||||
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "weakens" });
|
||||
|
||||
const e1 = makeEdge({
|
||||
id: "e1",
|
||||
fromNodeId: n1.id,
|
||||
toNodeId: n2.id,
|
||||
relationship: "supports",
|
||||
});
|
||||
const e2 = makeEdge({
|
||||
id: "e2",
|
||||
fromNodeId: n1.id,
|
||||
toNodeId: n2.id,
|
||||
relationship: "weakens",
|
||||
});
|
||||
|
||||
const dups = detectDuplicateEdges([e1, e2]);
|
||||
expect(dups.length).toBe(0);
|
||||
});
|
||||
@@ -174,9 +207,19 @@ describe("detectDuplicateEdges", () => {
|
||||
it("detects reversed direction as different edge", () => {
|
||||
const n1 = makeNode({ id: "n1", label: "A" });
|
||||
const n2 = makeNode({ id: "n2", label: "B" });
|
||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
||||
const e2 = makeEdge({ id: "e2", fromNodeId: n2.id, toNodeId: n1.id, relationship: "supports" });
|
||||
|
||||
const e1 = makeEdge({
|
||||
id: "e1",
|
||||
fromNodeId: n1.id,
|
||||
toNodeId: n2.id,
|
||||
relationship: "supports",
|
||||
});
|
||||
const e2 = makeEdge({
|
||||
id: "e2",
|
||||
fromNodeId: n2.id,
|
||||
toNodeId: n1.id,
|
||||
relationship: "supports",
|
||||
});
|
||||
|
||||
const dups = detectDuplicateEdges([e1, e2]);
|
||||
expect(dups.length).toBe(0);
|
||||
});
|
||||
@@ -229,7 +272,7 @@ describe("findDependentNodes (transitive)", () => {
|
||||
for (let i = 2; i <= 10; i++) {
|
||||
nodes[i - 1].dependsOn.push(nodes[0].id); // All depend on n1
|
||||
}
|
||||
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Chain",
|
||||
nodes,
|
||||
@@ -270,7 +313,14 @@ describe("findAffectedNodes (transitive)", () => {
|
||||
|
||||
it("handles empty graph", () => {
|
||||
// build a minimal graph without triggering schema validation for this edge case
|
||||
const graph = { centralStatement: "Empty", nodes: [], edges: [], resolvedNodeIds: [], currentSummary: "", activeUnknownNodeId: null };
|
||||
const graph = {
|
||||
centralStatement: "Empty",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "",
|
||||
activeUnknownNodeId: null,
|
||||
};
|
||||
const affected = findAffectedNodes(graph, "any-node");
|
||||
expect(affected.length).toBe(0);
|
||||
});
|
||||
@@ -279,7 +329,13 @@ describe("findAffectedNodes (transitive)", () => {
|
||||
describe("resolveUnknownNode", () => {
|
||||
it("returns success for valid node id", () => {
|
||||
const graph = makeTestGraph();
|
||||
const result = resolveUnknownNode(graph, "n4", "resolved", "Confirmed", "User confirmed");
|
||||
const result = resolveUnknownNode(
|
||||
graph,
|
||||
"n4",
|
||||
"resolved",
|
||||
"Confirmed",
|
||||
"User confirmed",
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatus).toBe("resolved");
|
||||
expect(result.reason).toBe("User confirmed");
|
||||
@@ -287,7 +343,13 @@ describe("resolveUnknownNode", () => {
|
||||
|
||||
it("returns error for non-existent node", () => {
|
||||
const graph = makeTestGraph();
|
||||
const result = resolveUnknownNode(graph, "ghost-node", "resolved", null, "reason");
|
||||
const result = resolveUnknownNode(
|
||||
graph,
|
||||
"ghost-node",
|
||||
"resolved",
|
||||
null,
|
||||
"reason",
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not found");
|
||||
});
|
||||
@@ -297,13 +359,25 @@ describe("resolveUnknownNode", () => {
|
||||
// n5 depends on... actually let's set up properly
|
||||
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
|
||||
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
|
||||
const result = resolveUnknownNode(graph, "n4", "resolved", "Yes", "Clarified");
|
||||
const result = resolveUnknownNode(
|
||||
graph,
|
||||
"n4",
|
||||
"resolved",
|
||||
"Yes",
|
||||
"Clarified",
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks previous status and value", () => {
|
||||
const graph = makeTestGraph();
|
||||
const result = resolveUnknownNode(graph, "n4", "known", "confirmed_value", "Evidence found");
|
||||
const result = resolveUnknownNode(
|
||||
graph,
|
||||
"n4",
|
||||
"known",
|
||||
"confirmed_value",
|
||||
"Evidence found",
|
||||
);
|
||||
expect(result.previousStatus).toBe("unknown");
|
||||
expect(result.newValue).toBe("confirmed_value");
|
||||
});
|
||||
@@ -313,7 +387,11 @@ describe("selectActiveUnknownCandidate", () => {
|
||||
it("returns null when no unresolved unknowns", () => {
|
||||
// makeTestGraph nodes default to kind "observation", not "unknown"
|
||||
// Create explicit unknown-kind nodes for this test
|
||||
const nUnknown = makeNode({ id: "n-unk-x", label: "Unknown X", kind: "unknown" });
|
||||
const nUnknown = makeNode({
|
||||
id: "n-unk-x",
|
||||
label: "Unknown X",
|
||||
kind: "unknown",
|
||||
});
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Test",
|
||||
nodes: [nUnknown],
|
||||
@@ -349,9 +427,21 @@ describe("selectActiveUnknownCandidate", () => {
|
||||
});
|
||||
|
||||
it("prioritises nodes with more dependents", () => {
|
||||
const unknownA = makeNode({ id: "unknown-a", label: "Unknown A", kind: "unknown" });
|
||||
const unknownB = makeNode({ id: "unknown-b", label: "Unknown B", kind: "unknown" });
|
||||
const dependent = makeNode({ id: "dep", label: "Dependent", kind: "state" });
|
||||
const unknownA = makeNode({
|
||||
id: "unknown-a",
|
||||
label: "Unknown A",
|
||||
kind: "unknown",
|
||||
});
|
||||
const unknownB = makeNode({
|
||||
id: "unknown-b",
|
||||
label: "Unknown B",
|
||||
kind: "unknown",
|
||||
});
|
||||
const dependent = makeNode({
|
||||
id: "dep",
|
||||
label: "Dependent",
|
||||
kind: "state",
|
||||
});
|
||||
|
||||
dependent.dependsOn.push("unknown-a");
|
||||
|
||||
@@ -370,7 +460,11 @@ describe("selectActiveUnknownCandidate", () => {
|
||||
|
||||
it("returns one candidate (not array)", () => {
|
||||
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
|
||||
const nUnknown = makeNode({ id: "n-unk", label: "Pending", kind: "unknown" });
|
||||
const nUnknown = makeNode({
|
||||
id: "n-unk",
|
||||
label: "Pending",
|
||||
kind: "unknown",
|
||||
});
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Test",
|
||||
nodes: [n1, nUnknown],
|
||||
@@ -386,13 +480,152 @@ describe("selectActiveUnknownCandidate", () => {
|
||||
expect(result.label).toBeDefined();
|
||||
expect(result.score).toBeDefined();
|
||||
});
|
||||
|
||||
it("commercial value wins over pricing", () => {
|
||||
const commercialValue = makeNode({
|
||||
id: "n-commercial-value",
|
||||
label: "Commercial value definition",
|
||||
description:
|
||||
"Need to define commercial value because the decision depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const pricing = makeNode({
|
||||
id: "n-pricing",
|
||||
label: "Target price point",
|
||||
description:
|
||||
"Need a target price point because revenue assumptions depend on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
dependsOn: ["n-commercial-value"],
|
||||
});
|
||||
const decision = makeNode({
|
||||
id: "n-decision",
|
||||
label: "Build decision",
|
||||
description: "Decision context",
|
||||
kind: "state",
|
||||
status: "supported",
|
||||
confidence: "medium",
|
||||
dependsOn: ["n-commercial-value", "n-pricing"],
|
||||
});
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Build decision",
|
||||
nodes: [commercialValue, pricing, decision],
|
||||
edges: [],
|
||||
activeUnknownNodeId: pricing.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Test",
|
||||
});
|
||||
|
||||
const result = selectActiveUnknownCandidate(graph, []);
|
||||
expect(result.nodeId).toBe("n-commercial-value");
|
||||
});
|
||||
|
||||
it("customer value wins over UI colour", () => {
|
||||
const customerValue = makeNode({
|
||||
id: "n-customer-value",
|
||||
label: "Customer value",
|
||||
description:
|
||||
"Need to know customer value because adoption depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const uiColour = makeNode({
|
||||
id: "n-ui-colour",
|
||||
label: "UI colour",
|
||||
description: "Need a UI colour because presentation choices remain open.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "low",
|
||||
});
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Value question",
|
||||
nodes: [customerValue, uiColour],
|
||||
edges: [],
|
||||
activeUnknownNodeId: null,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Test",
|
||||
});
|
||||
|
||||
const result = selectActiveUnknownCandidate(graph, []);
|
||||
expect(result.nodeId).toBe("n-customer-value");
|
||||
});
|
||||
|
||||
it("success criteria wins over marketing slogan", () => {
|
||||
const successCriteria = makeNode({
|
||||
id: "n-success-criteria",
|
||||
label: "Success criteria",
|
||||
description:
|
||||
"Need success criteria because the decision requires a threshold.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const slogan = makeNode({
|
||||
id: "n-slogan",
|
||||
label: "Marketing slogan",
|
||||
description: "Need a slogan because messaging is undecided.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "low",
|
||||
});
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Threshold question",
|
||||
nodes: [successCriteria, slogan],
|
||||
edges: [],
|
||||
activeUnknownNodeId: null,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Test",
|
||||
});
|
||||
|
||||
const result = selectActiveUnknownCandidate(graph, []);
|
||||
expect(result.nodeId).toBe("n-success-criteria");
|
||||
});
|
||||
|
||||
it("penalises unknowns with unresolved parent unknowns", () => {
|
||||
const parentUnknown = makeNode({
|
||||
id: "n-parent",
|
||||
label: "Commercial value definition",
|
||||
description:
|
||||
"Need commercial value definition because the decision depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const childUnknown = makeNode({
|
||||
id: "n-child",
|
||||
label: "Target price point",
|
||||
description: "Need price point because revenue assumptions depend on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
dependsOn: ["n-parent"],
|
||||
});
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Dependency ordering",
|
||||
nodes: [parentUnknown, childUnknown],
|
||||
edges: [],
|
||||
activeUnknownNodeId: null,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Test",
|
||||
});
|
||||
|
||||
const parentScore = scoreUnknownCandidate(graph, parentUnknown, []);
|
||||
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
|
||||
expect(parentScore.score).toBeGreaterThan(childScore.score);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyGraphUpdate", () => {
|
||||
it("applies node additions correctly", () => {
|
||||
const graph = makeTestGraph();
|
||||
const newNode = makeNode({ id: "n-new", label: "New Node" });
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [newNode],
|
||||
updatedNodes: [],
|
||||
@@ -401,65 +634,69 @@ describe("applyGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.nodes.length).toBe(graph.nodes.length + 1);
|
||||
expect(result.nodes.some(n => n.id === "n-new")).toBe(true);
|
||||
expect(result.nodes.some((n) => n.id === "n-new")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies status updates correctly", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "confirmed",
|
||||
reason: "Answered by user",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "confirmed",
|
||||
reason: "Answered by user",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: ["n4"],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const updatedNode = result.nodes.find(n => n.id === "n4");
|
||||
|
||||
const updatedNode = result.nodes.find((n) => n.id === "n4");
|
||||
expect(updatedNode.status).toBe("resolved");
|
||||
});
|
||||
|
||||
it("rejects update with non-existent nodeId in updatedNodes", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{
|
||||
nodeId: "ghost-node",
|
||||
previousStatus: null,
|
||||
newStatus: "known",
|
||||
reason: "test",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "ghost-node",
|
||||
previousStatus: null,
|
||||
newStatus: "known",
|
||||
reason: "test",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removes requested edges", () => {
|
||||
const graph = makeTestGraph();
|
||||
const edgeIdToRemove = graph.edges[0].id;
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [],
|
||||
@@ -468,17 +705,21 @@ describe("applyGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.edges.length).toBe(graph.edges.length - 1);
|
||||
expect(result.edges.some(e => e.id === edgeIdToRemove)).toBe(false);
|
||||
expect(result.edges.some((e) => e.id === edgeIdToRemove)).toBe(false);
|
||||
});
|
||||
|
||||
it("adds edges and updates node dependsOn/affects", () => {
|
||||
const graph = makeTestGraph();
|
||||
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n4", relationship: "supports" });
|
||||
|
||||
const newEdge = makeEdge({
|
||||
fromNodeId: "n1",
|
||||
toNodeId: "n4",
|
||||
relationship: "supports",
|
||||
});
|
||||
|
||||
const update = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [],
|
||||
@@ -487,23 +728,23 @@ describe("applyGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
|
||||
// Check the edge was added
|
||||
expect(result.edges.some(e => e.id === newEdge.id)).toBe(true);
|
||||
|
||||
expect(result.edges.some((e) => e.id === newEdge.id)).toBe(true);
|
||||
|
||||
// Check node relationship arrays updated
|
||||
const fromNode = result.nodes.find(n => n.id === "n1");
|
||||
const toNode = result.nodes.find(n => n.id === "n4");
|
||||
const fromNode = result.nodes.find((n) => n.id === "n1");
|
||||
const toNode = result.nodes.find((n) => n.id === "n4");
|
||||
expect(fromNode.childIds).toContain("n4");
|
||||
expect(toNode.dependsOn).toContain("n1");
|
||||
});
|
||||
|
||||
it("accumulates resolved node IDs", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [],
|
||||
@@ -512,7 +753,7 @@ describe("applyGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: ["n4"],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.resolvedNodeIds).toContain("n4");
|
||||
@@ -538,23 +779,25 @@ describe("applyGraphUpdate", () => {
|
||||
|
||||
it("rejects edges referencing non-existent nodes", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [],
|
||||
addedEdges: [{
|
||||
id: "e-new",
|
||||
fromNodeId: "missing-node",
|
||||
toNodeId: "n1",
|
||||
relationship: "supports",
|
||||
confidence: "medium",
|
||||
description: "bad edge",
|
||||
}],
|
||||
addedEdges: [
|
||||
{
|
||||
id: "e-new",
|
||||
fromNodeId: "missing-node",
|
||||
toNodeId: "n1",
|
||||
relationship: "supports",
|
||||
confidence: "medium",
|
||||
description: "bad edge",
|
||||
},
|
||||
],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
@@ -562,7 +805,7 @@ describe("applyGraphUpdate", () => {
|
||||
it("preserves nodes not mentioned in the update", () => {
|
||||
const graph = makeTestGraph();
|
||||
const unchangedCount = graph.nodes.length;
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [],
|
||||
@@ -571,7 +814,7 @@ describe("applyGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.nodes.length).toBe(unchangedCount);
|
||||
@@ -580,21 +823,23 @@ describe("applyGraphUpdate", () => {
|
||||
it("applies multiple operations in one update", () => {
|
||||
const graph = makeTestGraph();
|
||||
const newNode = makeNode({ id: "n-multi", label: "Multi" });
|
||||
|
||||
|
||||
const update = {
|
||||
addedNodes: [newNode],
|
||||
updatedNodes: [{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Multiple ops test",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Multiple ops test",
|
||||
},
|
||||
],
|
||||
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
|
||||
removedEdgeIds: [graph.edges[0]?.id || ""],
|
||||
resolvedUnknownNodeIds: ["n4"],
|
||||
affectedNodeIds: [],
|
||||
};
|
||||
|
||||
|
||||
const result = applyGraphUpdate(graph, update);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
@@ -604,7 +849,7 @@ describe("validateGraphUpdate", () => {
|
||||
it("accepts a no-op update with added nodes", () => {
|
||||
const graph = makeTestGraph();
|
||||
const newNode = makeNode({ id: "n-new", label: "New" });
|
||||
|
||||
|
||||
const result = validateGraphUpdate(graph, {
|
||||
addedNodes: [newNode],
|
||||
updatedNodes: [],
|
||||
@@ -613,37 +858,39 @@ describe("validateGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects update with no meaningful change", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const result = validateGraphUpdate(graph, {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{
|
||||
nodeId: "n1",
|
||||
previousStatus: null,
|
||||
newStatus: null,
|
||||
previousValue: null,
|
||||
newValue: null,
|
||||
reason: "No change test",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n1",
|
||||
previousStatus: null,
|
||||
newStatus: null,
|
||||
previousValue: null,
|
||||
newValue: null,
|
||||
reason: "No change test",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("no meaningful"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate node IDs in additions", () => {
|
||||
const graph = makeTestGraph();
|
||||
const existingNode = graph.nodes[0];
|
||||
|
||||
|
||||
const result = validateGraphUpdate(graph, {
|
||||
addedNodes: [existingNode], // Duplicate ID
|
||||
updatedNodes: [],
|
||||
@@ -652,54 +899,58 @@ describe("validateGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects update to non-existent node", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const result = validateGraphUpdate(graph, {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{
|
||||
nodeId: "ghost-node",
|
||||
previousStatus: null,
|
||||
newStatus: "known",
|
||||
reason: "test",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "ghost-node",
|
||||
previousStatus: null,
|
||||
newStatus: "known",
|
||||
reason: "test",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid status change as meaningful", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const result = validateGraphUpdate(graph, {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
reason: "Confirmed",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "known",
|
||||
reason: "Confirmed",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects oversized update (>100KB)", () => {
|
||||
const graph = makeTestGraph();
|
||||
const largeDescription = "x".repeat(150000);
|
||||
|
||||
|
||||
const result = validateGraphUpdate(graph, {
|
||||
addedNodes: [{ label: largeDescription }], // Will create huge JSON
|
||||
updatedNodes: [],
|
||||
@@ -708,14 +959,16 @@ describe("validateGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("100KB") || e.includes("exceeds"))).toBe(true);
|
||||
expect(
|
||||
result.errors.some((e) => e.includes("100KB") || e.includes("exceeds")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty errors array for valid update", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const result = validateGraphUpdate(graph, {
|
||||
addedNodes: [makeNode({ id: "n-valid", label: "Valid" })],
|
||||
updatedNodes: [],
|
||||
@@ -724,7 +977,7 @@ describe("validateGraphUpdate", () => {
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
@@ -735,47 +988,55 @@ describe("validateGraphUpdate", () => {
|
||||
describe("update lifecycle integration", () => {
|
||||
it("complete update cycle: validate → apply → verify", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
// Create a meaningful update
|
||||
const newNode = makeNode({ id: "n-new", label: "New Discovery" });
|
||||
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n-new", relationship: "supports" });
|
||||
|
||||
const newEdge = makeEdge({
|
||||
fromNodeId: "n1",
|
||||
toNodeId: "n-new",
|
||||
relationship: "supports",
|
||||
});
|
||||
|
||||
// Validate first
|
||||
const validationResult = validateGraphUpdate(graph, {
|
||||
addedNodes: [newNode],
|
||||
updatedNodes: [{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Answered via follow-up question",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Answered via follow-up question",
|
||||
},
|
||||
],
|
||||
addedEdges: [newEdge],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: ["n4"],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
expect(validationResult.valid).toBe(true);
|
||||
|
||||
|
||||
// Apply
|
||||
const applyResult = applyGraphUpdate(graph, {
|
||||
addedNodes: [newNode],
|
||||
updatedNodes: [{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Answered via follow-up question",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Answered via follow-up question",
|
||||
},
|
||||
],
|
||||
addedEdges: [newEdge],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: ["n4"],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
expect(applyResult.success).toBe(true);
|
||||
expect(applyResult.nodes.length).toBe(graph.nodes.length + 1);
|
||||
expect(applyResult.edges.length).toBe(graph.edges.length + 1);
|
||||
expect(applyResult.resolvedNodeIds).toContain("n4");
|
||||
|
||||
|
||||
// Verify post-apply integrity
|
||||
const postValidation = validateGraphReferences(applyResult);
|
||||
expect(postValidation.valid).toBe(true);
|
||||
@@ -783,10 +1044,12 @@ describe("update lifecycle integration", () => {
|
||||
|
||||
it("reject and retry: invalid update should be caught", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
|
||||
const invalidUpdate = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{ nodeId: "ghost-node", newStatus: "known", reason: "test" }],
|
||||
updatedNodes: [
|
||||
{ nodeId: "ghost-node", newStatus: "known", reason: "test" },
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
@@ -795,7 +1058,7 @@ describe("update lifecycle integration", () => {
|
||||
|
||||
// Validation should catch it
|
||||
expect(validateGraphUpdate(graph, invalidUpdate).valid).toBe(false);
|
||||
|
||||
|
||||
// Apply should also catch it
|
||||
expect(applyGraphUpdate(graph, invalidUpdate).success).toBe(false);
|
||||
});
|
||||
@@ -803,21 +1066,23 @@ describe("update lifecycle integration", () => {
|
||||
it("preserve unchanged nodes during update", () => {
|
||||
const graph = makeTestGraph();
|
||||
const originalNode1 = JSON.parse(JSON.stringify(graph.nodes[0]));
|
||||
|
||||
|
||||
applyGraphUpdate(graph, {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Test preserve",
|
||||
}],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n4",
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
reason: "Test preserve",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: ["n4"],
|
||||
affectedNodeIds: [],
|
||||
});
|
||||
|
||||
|
||||
// Re-read the graph and check n1 wasn't modified
|
||||
expect(graph.nodes[0].id).toBe("n1");
|
||||
expect(graph.nodes[0].status).toBe("unknown"); // unchanged
|
||||
|
||||
Reference in New Issue
Block a user