1322 lines
41 KiB
JavaScript
1322 lines
41 KiB
JavaScript
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 makeComparabilityUpdateFixture() {
|
|
const comparabilityUnknown = makeNode({
|
|
id: "n-comparability-unknown",
|
|
label: "Whether the figures are comparable",
|
|
description:
|
|
"Need to know whether the figures use the same period, basis, and scale before comparing them.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const revenueObservation = makeNode({
|
|
id: "n-revenue-observation",
|
|
label: "Revenue increased by 18%.",
|
|
description: "Revenue increased by 18%.",
|
|
kind: "observation",
|
|
status: "supported",
|
|
confidence: "high",
|
|
});
|
|
const cashObservation = makeNode({
|
|
id: "n-cash-observation",
|
|
label: "Cash in the bank decreased over the same period.",
|
|
description: "Cash in the bank decreased over the same period.",
|
|
kind: "observation",
|
|
status: "supported",
|
|
confidence: "high",
|
|
});
|
|
const unrelatedNode = makeNode({
|
|
id: "n-unrelated",
|
|
label: "Board update",
|
|
description: "A separate unchanged note.",
|
|
kind: "state",
|
|
status: "known",
|
|
confidence: "low",
|
|
});
|
|
|
|
const graph = makeGraph({
|
|
centralStatement:
|
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
|
nodes: [
|
|
comparabilityUnknown,
|
|
revenueObservation,
|
|
cashObservation,
|
|
unrelatedNode,
|
|
],
|
|
edges: [
|
|
makeEdge({
|
|
id: "e-revenue-comparability",
|
|
fromNodeId: revenueObservation.id,
|
|
toNodeId: comparabilityUnknown.id,
|
|
relationship: "supports",
|
|
confidence: "medium",
|
|
description: "Revenue observation requires comparability confirmation.",
|
|
}),
|
|
makeEdge({
|
|
id: "e-cash-comparability",
|
|
fromNodeId: cashObservation.id,
|
|
toNodeId: comparabilityUnknown.id,
|
|
relationship: "supports",
|
|
confidence: "medium",
|
|
description: "Cash observation requires comparability confirmation.",
|
|
}),
|
|
],
|
|
activeUnknownNodeId: comparabilityUnknown.id,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Initial comparability fixture",
|
|
reasoningState: {
|
|
comparabilityStatus: "uncertain",
|
|
comparabilityReason:
|
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
|
comparabilityEvidence: [],
|
|
relationshipStatus: "insufficient_information",
|
|
relationshipReason:
|
|
"Relationship classification is deferred until comparability is established.",
|
|
relationshipAssessed: false,
|
|
contradictionReasoningAllowed: false,
|
|
reasoningStages: [
|
|
{
|
|
stage: "comparability",
|
|
status: "uncertain",
|
|
outcome:
|
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
|
},
|
|
{
|
|
stage: "relationship",
|
|
status: "insufficient_information",
|
|
outcome: "not assessed until comparability is established",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const proposal = {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: comparabilityUnknown.id,
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: null,
|
|
newValue:
|
|
"Both figures cover the same accounting period and are taken from the same management accounts.",
|
|
reason: "The answer confirms the figures are comparable.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [comparabilityUnknown.id],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
};
|
|
|
|
return { graph, proposal, comparabilityUnknownId: comparabilityUnknown.id };
|
|
}
|
|
|
|
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],
|
|
selectedQuestion: null,
|
|
};
|
|
|
|
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,
|
|
);
|
|
expect(
|
|
result.updatedSituationGraph.nodes.find(
|
|
(node) => node.id === ids.complaintRateUnknown,
|
|
)?.status,
|
|
).toBe("resolved");
|
|
});
|
|
|
|
it("rejects resolvedUnknownNodeIds that do not reference actual unknown nodes", () => {
|
|
const { graph, proposal, ids } = makeApplicationFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal: {
|
|
...proposal,
|
|
resolvedUnknownNodeIds: [ids.qualityDeterioration],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stage).toBe("proposal_compatibility");
|
|
expect(result.errors.join(" ")).toContain(
|
|
"Resolved unknown must reference an existing unknown node",
|
|
);
|
|
});
|
|
|
|
it("rejects a duplicate semantic node without resolution", () => {
|
|
const { graph, proposal } = makeApplicationFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal: {
|
|
...proposal,
|
|
resolvedUnknownNodeIds: [],
|
|
updatedNodes: proposal.updatedNodes.filter(
|
|
(update) => update.nodeId !== "n-complaint-rate-unknown",
|
|
),
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-parallel-rate",
|
|
label: "Complaint rate",
|
|
description: "Need the complaint rate per 100 units",
|
|
kind: "observation",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stage).toBe("proposal_compatibility");
|
|
expect(result.errors.join(" ")).toContain(
|
|
"duplicating unresolved unknown meaning",
|
|
);
|
|
});
|
|
|
|
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: [],
|
|
selectedQuestion: null,
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
stage: "proposal_compatibility",
|
|
});
|
|
expect(result.errors).toEqual(
|
|
expect.arrayContaining([expect.stringContaining("no meaningful change")]),
|
|
);
|
|
});
|
|
|
|
it("resolves one unknown and adds consequential unknowns with one selected question", () => {
|
|
const { graph, ids } = makeApplicationFixture();
|
|
|
|
const proposal = {
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-commercial-value",
|
|
label: "Commercial value definition",
|
|
description:
|
|
"Need a concrete definition of commercial value because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
makeNode({
|
|
id: "n-demand-evidence",
|
|
label: "Evidence of demand",
|
|
description:
|
|
"Need evidence of demand because it matters to the build decision.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
}),
|
|
makeNode({
|
|
id: "n-build-decision",
|
|
label: "Build Confidence Engine decision",
|
|
description: "Decision situation 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-build-demand-evidence",
|
|
fromNodeId: "n-build-decision",
|
|
toNodeId: "n-demand-evidence",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description: "The decision depends on evidence of demand.",
|
|
}),
|
|
],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: {
|
|
nodeId: "n-commercial-value",
|
|
question: "How should commercial value be defined for this decision?",
|
|
reason:
|
|
"This is the most consequential unresolved unknown introduced by the answer.",
|
|
},
|
|
};
|
|
|
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
|
|
ids.complaintRateUnknown,
|
|
);
|
|
expect(
|
|
result.updatedSituationGraph.nodes.some(
|
|
(node) => node.id === "n-commercial-value",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
result.updatedSituationGraph.nodes.some(
|
|
(node) => node.id === "n-demand-evidence",
|
|
),
|
|
).toBe(true);
|
|
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
|
expect(result.selectedQuestion?.question).toMatch(/\?$/);
|
|
expect(result.selectedQuestion?.question.length).toBeGreaterThan(20);
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
|
"price",
|
|
);
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
|
"how should uncertainty regarding",
|
|
);
|
|
});
|
|
|
|
it("rejects more than 3 added unknowns", () => {
|
|
const { graph, proposal, ids } = makeApplicationFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal: {
|
|
...proposal,
|
|
addedNodes: [1, 2, 3, 4].map((index) =>
|
|
makeNode({
|
|
id: `n-unknown-${index}`,
|
|
label: `Unknown ${index}`,
|
|
description: `Need unknown ${index} because it matters to the decision.`,
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
}),
|
|
),
|
|
addedEdges: [1, 2, 3, 4].map((index) =>
|
|
makeEdge({
|
|
id: `e-unknown-${index}`,
|
|
fromNodeId: ids.complaintRateUnknown,
|
|
toNodeId: `n-unknown-${index}`,
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description: `Links unknown ${index}`,
|
|
}),
|
|
),
|
|
selectedQuestion: {
|
|
nodeId: "n-unknown-1",
|
|
question: "What is unknown 1?",
|
|
reason: "Follow-up required.",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.join(" ")).toContain("too many unknown nodes");
|
|
});
|
|
|
|
it("rejects unrelated added unknowns", () => {
|
|
const { graph, proposal } = makeApplicationFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal: {
|
|
...proposal,
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-unrelated",
|
|
label: "Office rent",
|
|
description:
|
|
"Need office rent because it matters to a different branch.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "low",
|
|
}),
|
|
],
|
|
selectedQuestion: {
|
|
nodeId: "n-unrelated",
|
|
question: "What is the office rent?",
|
|
reason: "Unrelated test.",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.join(" ")).toContain(
|
|
"explicitly related to an answer-derived node",
|
|
);
|
|
});
|
|
|
|
it("accepts a newly added unknown explicitly linked through answer-derived node fields", () => {
|
|
const { graph, ids } = makeApplicationFixture();
|
|
|
|
const proposal = {
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-answer-context",
|
|
label: "Build Confidence Engine decision",
|
|
description: "Decision context introduced by the answer.",
|
|
kind: "state",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
childIds: ["n-commercial-value"],
|
|
}),
|
|
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",
|
|
dependsOn: ["n-answer-context"],
|
|
}),
|
|
],
|
|
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: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: {
|
|
nodeId: "n-commercial-value",
|
|
question: "How should commercial value be defined for this decision?",
|
|
reason: "A consequential unknown remains unresolved.",
|
|
},
|
|
};
|
|
|
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
|
});
|
|
|
|
it("rejects a newly added unknown linked only to the original unresolved node when that node is not answer-derived", () => {
|
|
const { graph, ids } = makeApplicationFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
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",
|
|
dependsOn: [ids.complaintRateUnknown],
|
|
}),
|
|
],
|
|
updatedNodes: [],
|
|
addedEdges: [
|
|
makeEdge({
|
|
id: "e-legacy-unknown-commercial-value",
|
|
fromNodeId: ids.complaintRateUnknown,
|
|
toNodeId: "n-commercial-value",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description: "Links only to the original unresolved unknown.",
|
|
}),
|
|
],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: {
|
|
nodeId: "n-commercial-value",
|
|
question: "How should commercial value be defined for this decision?",
|
|
reason: "A consequential unknown remains unresolved.",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.join(" ")).toContain(
|
|
"explicitly related to an answer-derived node",
|
|
);
|
|
});
|
|
|
|
it("rejects a floating emergent unknown with no explicit relationship", () => {
|
|
const { graph } = makeApplicationFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal: {
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-floating",
|
|
label: "Floating unknown",
|
|
description: "Need this because it matters to the decision.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
}),
|
|
],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: {
|
|
nodeId: "n-floating",
|
|
question: "What would resolve Floating unknown?",
|
|
reason: "Test case for floating unknown rejection.",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.join(" ")).toContain(
|
|
"explicitly related to an answer-derived node",
|
|
);
|
|
});
|
|
|
|
it("accepts the reported live-shaped commercial-value proposal when the linkage is explicit in node references", () => {
|
|
const { graph, ids } = makeApplicationFixture();
|
|
|
|
const proposal = {
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "answer_context_build",
|
|
label: "Build Confidence Engine decision context",
|
|
description:
|
|
"The answer introduces a concrete decision about whether to build Confidence Engine.",
|
|
kind: "state",
|
|
status: "known",
|
|
confidence: "high",
|
|
dependsOn: [ids.complaintRateUnknown, "nu_commercial_val"],
|
|
childIds: ["nu_commercial_val"],
|
|
affects: ["nu_commercial_val"],
|
|
}),
|
|
makeNode({
|
|
id: "nu_commercial_val",
|
|
label: "Commercial viability assessment of Confidence Engine",
|
|
description:
|
|
"The commercial viability of Confidence Engine remains unknown because resolving it is needed to decide whether building it is justified.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
dependsOn: ["answer_context_build"],
|
|
childIds: ["answer_context_build"],
|
|
}),
|
|
],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: ids.complaintRateUnknown,
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: null,
|
|
newValue:
|
|
"Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.",
|
|
reason: "The answer resolves the original context unknown.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
|
affectedNodeIds: [
|
|
ids.complaintRateUnknown,
|
|
"answer_context_build",
|
|
"nu_commercial_val",
|
|
],
|
|
selectedQuestion: {
|
|
nodeId: "nu_commercial_val",
|
|
question:
|
|
"How should commercial viability be defined for this decision?",
|
|
reason: "A foundational commercial-value unknown remains unresolved.",
|
|
},
|
|
};
|
|
|
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
|
|
ids.complaintRateUnknown,
|
|
);
|
|
expect(
|
|
result.updatedSituationGraph.nodes.some(
|
|
(node) => node.id === "nu_commercial_val",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects selected question referencing resolved node", () => {
|
|
const { graph, proposal, ids } = makeApplicationFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal: {
|
|
...proposal,
|
|
selectedQuestion: {
|
|
nodeId: ids.complaintRateUnknown,
|
|
question: "What is the complaint rate?",
|
|
reason: "Invalid reselection.",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.join(" ")).toContain(
|
|
"selectedQuestion must reference an unresolved node",
|
|
);
|
|
});
|
|
|
|
it("active unknown matches selected question node", () => {
|
|
const { graph, ids } = makeApplicationFixture();
|
|
|
|
const proposal = {
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-success-threshold",
|
|
label: "Success threshold",
|
|
description:
|
|
"Need a success threshold because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
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 unknown.",
|
|
},
|
|
],
|
|
addedEdges: [
|
|
makeEdge({
|
|
id: "e-build-success-threshold",
|
|
fromNodeId: "n-build-decision",
|
|
toNodeId: "n-success-threshold",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description: "The decision depends on a success threshold.",
|
|
}),
|
|
],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: {
|
|
nodeId: "n-success-threshold",
|
|
question: "What success threshold would justify building it?",
|
|
reason: "One consequential unknown remains.",
|
|
},
|
|
};
|
|
|
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
|
|
|
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",
|
|
);
|
|
});
|
|
|
|
it("resolves the existing comparability unknown and advances reasoning after the answer", () => {
|
|
const { graph, proposal, comparabilityUnknownId } =
|
|
makeComparabilityUpdateFixture();
|
|
const originalUnrelatedNode = JSON.stringify(
|
|
graph.nodes.find((node) => node.id === "n-unrelated"),
|
|
);
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal,
|
|
previousQuestion:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
answer:
|
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.resolvedUnknownNodeIds).toContain(comparabilityUnknownId);
|
|
expect(result.resolvedReasoningNodeIds).toEqual([
|
|
"reasoning:comparability",
|
|
]);
|
|
expect(result.emergentReasoningNodeCreated).toBe(true);
|
|
expect(result.emergentReasoningNodeId).toBeTruthy();
|
|
expect(result.emergentReasoningNodeReason).toContain("backed by the graph");
|
|
expect(result.previousReasoningState.comparabilityStatus).toBe("uncertain");
|
|
expect(result.reasoningState).toMatchObject({
|
|
comparabilityStatus: "confirmed",
|
|
relationshipStatus: "potentially_related",
|
|
relationshipAssessed: true,
|
|
});
|
|
expect(result.reasoningState.comparabilityEvidence).toEqual([
|
|
comparabilityUnknownId,
|
|
]);
|
|
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
|
expect(result.selectedQuestion).toMatchObject({
|
|
nodeId: result.newActiveUnknownNodeId,
|
|
question:
|
|
"What evidence would clarify how the two observations were measured?",
|
|
});
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
|
/dso|debtor days|receivables turnover|working capital|receivables/,
|
|
);
|
|
expect(result.reasoningState.reasoningStages).toEqual([
|
|
{
|
|
stage: "comparability",
|
|
status: "confirmed",
|
|
outcome:
|
|
"Comparability was confirmed by the user answer covering the same period and source basis.",
|
|
},
|
|
{
|
|
stage: "relationship",
|
|
status: "potentially_related",
|
|
outcome:
|
|
"The observations concern connected business signals but do not establish a direct contradiction or cause.",
|
|
},
|
|
]);
|
|
const emergentNode = result.updatedSituationGraph.nodes.find(
|
|
(node) => node.id === result.emergentReasoningNodeId,
|
|
);
|
|
expect(emergentNode).toMatchObject({
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
});
|
|
expect(emergentNode.description.toLowerCase()).toContain("because");
|
|
expect(
|
|
result.updatedSituationGraph.edges.filter(
|
|
(edge) => edge.toNodeId === result.emergentReasoningNodeId,
|
|
),
|
|
).not.toEqual([]);
|
|
expect(
|
|
result.updatedSituationGraph.edges.some(
|
|
(edge) =>
|
|
edge.toNodeId === result.emergentReasoningNodeId &&
|
|
edge.relationship === "causes",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
JSON.stringify(
|
|
result.updatedSituationGraph.nodes.find(
|
|
(node) => node.id === "n-unrelated",
|
|
),
|
|
),
|
|
).toBe(originalUnrelatedNode);
|
|
});
|
|
|
|
it("reuses an equivalent existing unresolved reasoning unknown instead of creating a duplicate", () => {
|
|
const { graph, proposal } = makeComparabilityUpdateFixture();
|
|
graph.nodes.push(
|
|
makeNode({
|
|
id: "n-existing-explanation",
|
|
label:
|
|
"Explanation for why Revenue increased by 18%, but cash in the bank fell over the same period",
|
|
description:
|
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
}),
|
|
);
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal,
|
|
previousQuestion:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
answer:
|
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.emergentReasoningNodeCreated).toBe(false);
|
|
expect(result.emergentReasoningNodeId).toBe("n-existing-explanation");
|
|
expect(result.newActiveUnknownNodeId).not.toBe("n-existing-explanation");
|
|
expect(result.selectedQuestion?.nodeId).not.toBe("n-existing-explanation");
|
|
expect(result.selectedQuestion?.question).toBe(
|
|
"What evidence would clarify how the two observations were measured?",
|
|
);
|
|
expect(
|
|
result.updatedSituationGraph.nodes.filter(
|
|
(node) => node.label === graph.nodes.at(-1).label,
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("decomposes a composite selected unknown before asking the next question", () => {
|
|
const { graph, proposal } = makeComparabilityUpdateFixture();
|
|
|
|
const result = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal,
|
|
previousQuestion:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
answer:
|
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.atomicityAssessment).toBe("composite");
|
|
expect(result.decompositionPerformed).toBe(true);
|
|
expect(result.childUnknownCount).toBe(5);
|
|
expect(result.childNodeIds).toHaveLength(5);
|
|
expect(result.atomicityReason).toBeTruthy();
|
|
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
|
expect(result.selectedQuestion?.nodeId).not.toBe(
|
|
result.emergentReasoningNodeId,
|
|
);
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
|
/dso|working capital|receivables|capex/,
|
|
);
|
|
|
|
const parentNode = result.updatedSituationGraph.nodes.find(
|
|
(node) => node.id === result.emergentReasoningNodeId,
|
|
);
|
|
expect(parentNode?.status).toBe("unknown");
|
|
|
|
const childNodes = result.updatedSituationGraph.nodes.filter((node) =>
|
|
result.childNodeIds.includes(node.id),
|
|
);
|
|
expect(childNodes).toHaveLength(5);
|
|
expect(childNodes.every((node) => node.parentId === parentNode.id)).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
result.updatedSituationGraph.edges.filter(
|
|
(edge) =>
|
|
result.childNodeIds.includes(edge.fromNodeId) &&
|
|
edge.toNodeId === parentNode.id &&
|
|
edge.relationship === "depends_on",
|
|
),
|
|
).toHaveLength(5);
|
|
});
|
|
|
|
it("reuses existing decomposition children instead of duplicating them", () => {
|
|
const { graph, proposal } = makeComparabilityUpdateFixture();
|
|
|
|
const firstResult = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal,
|
|
previousQuestion:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
answer:
|
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
|
});
|
|
|
|
expect(firstResult.success).toBe(true);
|
|
|
|
const secondResult = applyValidatedProposal({
|
|
situationGraph: graph,
|
|
proposal,
|
|
previousQuestion:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
answer:
|
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
|
});
|
|
|
|
expect(secondResult.success).toBe(true);
|
|
expect(secondResult.atomicityAssessment).toBe("composite");
|
|
const uniqueChildIds = new Set(firstResult.childNodeIds);
|
|
expect(uniqueChildIds.size).toBe(firstResult.childNodeIds.length);
|
|
expect(
|
|
secondResult.updatedSituationGraph.nodes.filter((node) =>
|
|
firstResult.childNodeIds.includes(node.id),
|
|
),
|
|
).toHaveLength(firstResult.childNodeIds.length);
|
|
});
|
|
});
|