1665 lines
57 KiB
JavaScript
1665 lines
57 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
|
import { determineGraphBackedQuestion } from "@/lib/graph/apply-proposal.js";
|
|
import { validateGraphReferences } from "@/lib/graph/utils.js";
|
|
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
|
import liveProductLaunchStartResponse from "@/tests/fixtures/live-product-launch-start-response.json";
|
|
|
|
const mockAnalyseScenario = vi.fn();
|
|
const MOCK_CONFIG = { OLLAMA_MODEL: "configured" };
|
|
|
|
vi.mock("@/lib/analysis.js", () => ({
|
|
analyseScenario: (...args) => mockAnalyseScenario(...args),
|
|
}));
|
|
|
|
function makeAnalysisResult(overrides = {}) {
|
|
return {
|
|
success: true,
|
|
validationStatus: "valid",
|
|
modelName: "configured-model",
|
|
responseDurationMs: 321,
|
|
rawResponse: undefined,
|
|
promptVersion: "v0.3",
|
|
reconstruction: {
|
|
summary: "Revenue and complaints diverge",
|
|
actors: [],
|
|
systemsOrObjects: [],
|
|
expectedStates: [],
|
|
observedStates: [
|
|
{
|
|
id: "obs-1",
|
|
label: "Revenue up",
|
|
description: "Revenue up 15%",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
differences: [],
|
|
knownTransitions: [],
|
|
unexplainedTransitions: [],
|
|
contradictions: [],
|
|
importantUnknowns: [
|
|
{
|
|
id: "unk-1",
|
|
label: "Complaint rate denominator",
|
|
description: "Need the denominator for complaint rate",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
plausibleInterpretations: [],
|
|
},
|
|
evidence: [],
|
|
nextQuestion: {
|
|
id: "q-1",
|
|
question: "What denominator is being used for the complaint rate?",
|
|
},
|
|
compatibilityApplied: false,
|
|
compatibilityChanges: [],
|
|
compatibilityWarnings: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeCommercialAnalysisResult(overrides = {}) {
|
|
return makeAnalysisResult({
|
|
reconstruction: {
|
|
summary:
|
|
"A new reasoning method may become a commercial product, but problem existence and value remain unresolved.",
|
|
actors: [],
|
|
systemsOrObjects: [],
|
|
expectedStates: [],
|
|
observedStates: [],
|
|
differences: [],
|
|
knownTransitions: [],
|
|
unexplainedTransitions: [],
|
|
contradictions: [],
|
|
importantUnknowns: [
|
|
{
|
|
id: "unk-commercial",
|
|
label:
|
|
"Commercial justification for whether continuing development is commercially justified",
|
|
description:
|
|
"Need to know whether this solves a genuine problem, whether people would value it enough to pay for it, and whether it is commercially justified before continuing development.",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
plausibleInterpretations: [],
|
|
},
|
|
nextQuestion: {
|
|
id: "q-commercial",
|
|
question:
|
|
"What specific validation metrics, pilot feedback, or competitive benchmarking results have you collected to measure whether the method solves a recognized problem and how target users evaluate its practical utility compared to existing tools?",
|
|
reason: "Model-proposed broad validation question",
|
|
},
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function makeCommercialTieAnalysisResult(overrides = {}) {
|
|
return makeAnalysisResult({
|
|
reconstruction: {
|
|
summary:
|
|
"A new reasoning method may become a commercial product, but multiple broad decision unknowns remain unresolved.",
|
|
actors: [],
|
|
systemsOrObjects: [],
|
|
expectedStates: [],
|
|
observedStates: [],
|
|
differences: [],
|
|
knownTransitions: [],
|
|
unexplainedTransitions: [],
|
|
contradictions: [],
|
|
importantUnknowns: [
|
|
{
|
|
id: "unk-fit-pay",
|
|
label:
|
|
"Evidence of genuine problem-solution fit and actual willingness to pay among target users",
|
|
description:
|
|
"Need to know whether there is real problem-solution fit and willingness to pay among target users before continuing development.",
|
|
confidence: "high",
|
|
},
|
|
{
|
|
id: "unk-distinction",
|
|
label:
|
|
"Clear, measurable distinction between the method and existing AI tools that justifies separate commercial value",
|
|
description:
|
|
"Need to know whether there is a clear measurable distinction from existing AI tools before continuing development.",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
plausibleInterpretations: [],
|
|
},
|
|
nextQuestion: {
|
|
id: "q-commercial-tie",
|
|
question:
|
|
"What specific validation metrics, pilot feedback, or competitive benchmarking results have you collected?",
|
|
reason: "Model-proposed broad validation question",
|
|
},
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function makeCommercialUpdateGraph() {
|
|
const parent = makeNode({
|
|
id: "n-commercial-parent",
|
|
label:
|
|
"Commercial justification for whether continuing development is commercially justified",
|
|
description:
|
|
"Need to know whether this solves a genuine problem, whether people would value it enough to pay for it, and whether it is commercially justified before continuing development.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
});
|
|
|
|
return makeGraph({
|
|
centralStatement:
|
|
"I have developed a new reasoning method that aims to help people determine whether they have enough justified confidence to make a decision. I believe it could become a commercial product, but I do not yet know whether it solves a genuine problem, whether people would value it enough to pay for it, or whether it is fundamentally different from existing AI tools. Before investing significant time and money into building it further, I want to determine whether continuing development is commercially justified.",
|
|
nodes: [parent],
|
|
edges: [],
|
|
activeUnknownNodeId: parent.id,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Commercial update scenario",
|
|
});
|
|
}
|
|
|
|
function makeUpdateGraph() {
|
|
const unknown = makeNode({
|
|
id: "n-unknown",
|
|
label: "Complaint rate denominator",
|
|
description: "Need the denominator for the complaint rate",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
});
|
|
const observation = makeNode({
|
|
id: "n-observation",
|
|
label: "Complaint count rose",
|
|
description: "Complaint count rose faster than output",
|
|
kind: "observation",
|
|
status: "supported",
|
|
confidence: "high",
|
|
});
|
|
|
|
return makeGraph({
|
|
centralStatement:
|
|
"Complaint counts increased while production also increased.",
|
|
nodes: [unknown, observation],
|
|
edges: [],
|
|
activeUnknownNodeId: unknown.id,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Nodes: 1 unknown, 1 observation | Edges: 0 total",
|
|
});
|
|
}
|
|
|
|
function makeUpdateRequest(overrides = {}) {
|
|
return {
|
|
situationGraph: makeUpdateGraph(),
|
|
previousQuestion: "What denominator is being used for the complaint rate?",
|
|
answer:
|
|
"The complaint rate fell from 2.0 complaints per 100 units to 1.9 complaints per 100 units.",
|
|
promptVersion: "v0.4",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeProposal(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 rate.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n-unknown"],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ── v0.49: finding factories for post-authoritative seam test ──
|
|
|
|
/** Finding with null disposition → eligible provisional working interpretation */
|
|
function nullDispositionFinding() {
|
|
return {
|
|
id: "finding-a",
|
|
proposition: "Production volume increased by 12% in Q3.",
|
|
status: "provisional",
|
|
userDisposition: null,
|
|
originatingTargetNodeId: "n-unknown",
|
|
contributionId: "contrib-test-a",
|
|
sourceObservation: "user_input",
|
|
};
|
|
}
|
|
|
|
/** Finding with not_relevant disposition → excluded from understanding */
|
|
function notRelevantFinding() {
|
|
return {
|
|
id: "finding-b",
|
|
proposition: "Office humidity levels fluctuate seasonally.",
|
|
status: "provisional",
|
|
userDisposition: "not_relevant",
|
|
originatingTargetNodeId: "n-unknown",
|
|
contributionId: "contrib-test-b",
|
|
sourceObservation: "user_input",
|
|
};
|
|
}
|
|
|
|
function makeComparabilityScenarioGraph() {
|
|
return makeGraph({
|
|
centralStatement:
|
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
|
nodes: [
|
|
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",
|
|
}),
|
|
makeNode({
|
|
id: "n-revenue-observation",
|
|
label: "Revenue increased by 18%.",
|
|
description: "Revenue increased by 18%.",
|
|
kind: "observation",
|
|
status: "supported",
|
|
confidence: "high",
|
|
}),
|
|
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",
|
|
}),
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "n-comparability-unknown",
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Comparability scenario",
|
|
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",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
function makeComparabilityProposal() {
|
|
return {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n-comparability-unknown",
|
|
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 comparability.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n-comparability-unknown"],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
};
|
|
}
|
|
|
|
describe("lib/graph/orchestrator startCase", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("passes a valid request through to analyseScenario", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({
|
|
scenario: "Revenue increased while complaint counts rose faster.",
|
|
promptVersion: "v0.3",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockAnalyseScenario).toHaveBeenCalledWith(
|
|
"Revenue increased while complaint counts rose faster.",
|
|
{ promptVersion: "v0.3" },
|
|
);
|
|
});
|
|
|
|
it("rejects invalid request input without throwing", async () => {
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "" });
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: "Invalid start-case request",
|
|
statusCode: 400,
|
|
});
|
|
expect(result.validationErrors).toBeInstanceOf(Array);
|
|
expect(mockAnalyseScenario).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("builds a valid graph on successful analysis", async () => {
|
|
const analysis = makeAnalysisResult();
|
|
mockAnalyseScenario.mockResolvedValue(analysis);
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.reconstruction).toBe(analysis.reconstruction);
|
|
expect(result.situationGraph.centralStatement).toBe("Scenario text");
|
|
expect(result.situationGraph.currentSummary).toContain("Nodes:");
|
|
expect(result.diagnostics).toMatchObject({
|
|
validationStatus: "valid",
|
|
modelName: "configured-model",
|
|
graphReferenceValidation: { valid: true, errors: [] },
|
|
});
|
|
});
|
|
|
|
it("applies active unknown selection to the graph", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.situationGraph.activeUnknownNodeId).toBeTruthy();
|
|
expect(result.diagnostics.unknownSelectionExplanation?.selectedNodeId).toBe(
|
|
result.situationGraph.activeUnknownNodeId,
|
|
);
|
|
});
|
|
|
|
it("returns an ambiguous tie result instead of choosing by label order", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(
|
|
makeAnalysisResult({
|
|
reconstruction: {
|
|
summary: "Revenue up while cash falls",
|
|
actors: [],
|
|
systemsOrObjects: [],
|
|
expectedStates: [],
|
|
observedStates: [
|
|
{
|
|
id: "obs-1",
|
|
label: "Revenue increased by 18%.",
|
|
description: "Revenue increased by 18%.",
|
|
confidence: "high",
|
|
},
|
|
{
|
|
id: "obs-2",
|
|
label: "Cash in the bank decreased over the same period.",
|
|
description: "Cash in the bank decreased over the same period.",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
differences: [],
|
|
knownTransitions: [],
|
|
unexplainedTransitions: [],
|
|
contradictions: [
|
|
{
|
|
id: "c-1",
|
|
label:
|
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
|
description:
|
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
|
confidence: "medium",
|
|
},
|
|
],
|
|
importantUnknowns: [
|
|
{
|
|
id: "unk-1",
|
|
label:
|
|
"Whether revenue recognition timing differs from cash collection timing.",
|
|
description:
|
|
"Whether revenue recognition timing differs from cash collection timing.",
|
|
confidence: "high",
|
|
},
|
|
{
|
|
id: "unk-2",
|
|
label:
|
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
|
description:
|
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
|
confidence: "high",
|
|
},
|
|
],
|
|
plausibleInterpretations: [],
|
|
},
|
|
}),
|
|
);
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({
|
|
scenario:
|
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.situationGraph.activeUnknownNodeId).toBeNull();
|
|
expect(result.selectedQuestion).toMatchObject({
|
|
id: "q_tie_resolution",
|
|
selectionStatus: "ambiguous",
|
|
question:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
tiedCandidateIds: expect.arrayContaining([expect.any(String)]),
|
|
comparabilityStatus: "uncertain",
|
|
relationshipStatus: "insufficient_information",
|
|
relationshipAssessed: false,
|
|
contradictionReasoningAllowed: false,
|
|
});
|
|
expect(result.diagnostics.unknownSelectionExplanation).toMatchObject({
|
|
status: "ambiguous",
|
|
tieType: "complete_unresolved_tie",
|
|
selectedNodeId: null,
|
|
alphabeticalUsedAsReasoning: false,
|
|
tieResolutionQuestion:
|
|
"Were these figures measured on the same basis and at the same scale?",
|
|
});
|
|
});
|
|
|
|
it("returns structured failure when graph reference validation fails", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
|
|
const utils = await import("@/lib/graph/utils.js");
|
|
const validateSpy = vi
|
|
.spyOn(utils, "validateGraphReferences")
|
|
.mockReturnValue({
|
|
valid: false,
|
|
errors: ['Edge references non-existent toNodeId "missing"'],
|
|
});
|
|
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: "Situation graph reference validation failed",
|
|
validationErrors: ['Edge references non-existent toNodeId "missing"'],
|
|
statusCode: 500,
|
|
});
|
|
expect(result.diagnostics.graphReferenceValidation.valid).toBe(false);
|
|
validateSpy.mockRestore();
|
|
});
|
|
|
|
it("preserves analysis/provider failure details", async () => {
|
|
mockAnalyseScenario.mockResolvedValue({
|
|
success: false,
|
|
error: "Provider unavailable",
|
|
errors: ["socket hang up"],
|
|
rawResponse: null,
|
|
modelName: "configured-model",
|
|
responseDurationMs: 99,
|
|
promptVersion: "v0.3",
|
|
validationStatus: "invalid",
|
|
statusCode: 502,
|
|
});
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: "Provider unavailable",
|
|
analysisErrors: ["socket hang up"],
|
|
statusCode: 502,
|
|
});
|
|
expect(result).toHaveProperty("rawResponse");
|
|
});
|
|
|
|
it("preserves structured reconstruction validation issues on analysis failure", async () => {
|
|
const validationIssues = [
|
|
{
|
|
path: ["reconstruction", "observedStates", 2, "description"],
|
|
code: "invalid_type",
|
|
message: "Required",
|
|
expected: "string",
|
|
received: "undefined",
|
|
},
|
|
];
|
|
mockAnalyseScenario.mockResolvedValue({
|
|
success: false,
|
|
error: "Scenario analysis failed",
|
|
errors: ["reconstruction: Required"],
|
|
validationIssues,
|
|
rawResponse: "x".repeat(2501),
|
|
});
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
statusCode: 502,
|
|
analysisErrors: ["reconstruction: Required"],
|
|
validationIssues,
|
|
});
|
|
expect(result.rawResponse).toHaveLength(2501);
|
|
});
|
|
|
|
it("returns null selectedQuestion when neither analysis nor graph path yields a question", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(
|
|
makeAnalysisResult({
|
|
nextQuestion: undefined,
|
|
reconstruction: {
|
|
...makeAnalysisResult().reconstruction,
|
|
importantUnknowns: [],
|
|
},
|
|
}),
|
|
);
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion).toBeNull();
|
|
});
|
|
|
|
it("uses the graph-backed question path instead of the reconstruction nextQuestion on startCase", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(makeCommercialAnalysisResult());
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({
|
|
scenario:
|
|
"I have developed a new reasoning method that aims to help people determine whether they have enough justified confidence to make a decision.",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion?.question).toBe(
|
|
"Who experiences this problem?",
|
|
);
|
|
expect(result.selectedQuestion?.nodeId).toBe(
|
|
result.situationGraph.activeUnknownNodeId,
|
|
);
|
|
expect(result.selectedQuestion?.question).not.toContain(
|
|
"validation metrics, pilot feedback, or competitive benchmarking",
|
|
);
|
|
expect(result.diagnostics.reconstructionQuestion).toContain(
|
|
"validation metrics, pilot feedback, or competitive benchmarking",
|
|
);
|
|
expect(result.diagnostics.reconstructionQuestionAccepted).toBe(false);
|
|
expect(result.diagnostics.reconstructionQuestionRejectionReasons).toContain(
|
|
"graph_backed_pipeline_required",
|
|
);
|
|
expect(result.diagnostics.finalGraphBackedQuestion).toBe(
|
|
"Who experiences this problem?",
|
|
);
|
|
expect(result.diagnostics.selectedUnknownNodeId).toBe(
|
|
result.situationGraph.activeUnknownNodeId,
|
|
);
|
|
});
|
|
|
|
it("reselects and decomposes a tied commercial start-case candidate instead of returning a silent null question", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(makeCommercialTieAnalysisResult());
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({
|
|
scenario:
|
|
"I have developed a new reasoning method that aims to help people determine whether they have enough justified confidence to make a decision. I believe it could become a commercial product, but I do not yet know whether it solves a genuine problem, whether people would value it enough to pay for it, or whether it is fundamentally different from existing AI tools. Before investing significant time and money into building it further, I want to determine whether continuing development is commercially justified.",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(
|
|
result.situationGraph.nodes.some((node) => node.kind === "unknown"),
|
|
).toBe(true);
|
|
expect(result.selectedQuestion).not.toBeNull();
|
|
expect(result.selectedQuestion?.question).toBe(
|
|
"Who experiences this problem?",
|
|
);
|
|
expect(result.selectedQuestion?.nodeId).toBe(
|
|
result.situationGraph.activeUnknownNodeId,
|
|
);
|
|
expect(result.diagnostics.noQuestionReason).toBeNull();
|
|
expect(result.diagnostics.selectedContainerUnknown).toBeTruthy();
|
|
expect(result.diagnostics.selectedChildUnknown).toBeTruthy();
|
|
expect(result.diagnostics.decompositionApplied).toBe(true);
|
|
expect(result.diagnostics.reasoningPatternValidation).toMatchObject({
|
|
activePattern: "decision",
|
|
valid: true,
|
|
});
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
|
/pay|price|pricing|budget|benchmark/,
|
|
);
|
|
});
|
|
|
|
it("retains ownership when the strongest target's formulated question is rejected", () => {
|
|
const situationGraph = structuredClone(
|
|
liveProductLaunchStartResponse.situationGraph,
|
|
);
|
|
|
|
const result = determineGraphBackedQuestion({ situationGraph });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.deterministicSelection?.nodeId ?? null).toBe("ntpt9ki");
|
|
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe("ntpt9ki");
|
|
expect(result.selectedQuestion).toBeNull();
|
|
expect(result.noQuestionReason).toBe(
|
|
"The selected investigation target remains active, but its current graph-backed question formulation was rejected as too complex.",
|
|
);
|
|
expect(result.selectedChildUnknown).toBe("ntpt9ki");
|
|
expect(result.selectedUnknownAfter).toBe("ntpt9ki");
|
|
expect(result.selectedQuestion?.nodeId ?? null).toBe(null);
|
|
});
|
|
|
|
it("replays the captured live product-launch start graph through deterministic graph-backed question selection", () => {
|
|
const situationGraph = liveProductLaunchStartResponse.situationGraph;
|
|
|
|
const result = determineGraphBackedQuestion({ situationGraph });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe("ntpt9ki");
|
|
expect(result.deterministicSelection?.nodeId ?? null).toBe("ntpt9ki");
|
|
expect(result.answerabilityAssessment?.independentlyAnswerable).toBe(false);
|
|
expect(result.answerabilityAssessment?.decompositionRequired).toBe(true);
|
|
expect(result.decompositionAttempted).toBe(true);
|
|
expect(result.decompositionPerformed).toBe(false);
|
|
expect(result.selectedChildUnknown).toBe("ntpt9ki");
|
|
expect(result.selectedUnknownAfter).toBe("ntpt9ki");
|
|
expect(result.selectedQuestion).toBeNull();
|
|
expect(result.noQuestionReason).toBe(
|
|
"The selected investigation target remains active, but its current graph-backed question formulation was rejected as too complex.",
|
|
);
|
|
expect(result.deterministicSelection?.nodeId).not.toBe("nxmeiab");
|
|
});
|
|
|
|
it("includes compatibility diagnostics when provided by analysis", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(
|
|
makeAnalysisResult({
|
|
compatibilityApplied: true,
|
|
compatibilityChanges: [
|
|
{
|
|
path: ["evidence", 0, "source"],
|
|
change: "Converted null source to undefined",
|
|
},
|
|
],
|
|
compatibilityWarnings: [
|
|
"Applied deterministic reconstruction compatibility normalisation",
|
|
],
|
|
}),
|
|
);
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result.diagnostics.compatibilityApplied).toBe(true);
|
|
expect(result.diagnostics.compatibilityChanges).toHaveLength(1);
|
|
});
|
|
|
|
it("preserves selected question bytes while adding selection explanation diagnostics", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(
|
|
makeProposal({
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-build-decision",
|
|
label: "Build Confidence Engine decision",
|
|
description: "Decision introduced by the answer.",
|
|
kind: "state",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
}),
|
|
makeNode({
|
|
id: "n-commercial-value",
|
|
label: "Commercial value definition",
|
|
description:
|
|
"Need a concrete definition because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
],
|
|
addedEdges: [
|
|
{
|
|
id: "e-build-commercial-value",
|
|
fromNodeId: "n-build-decision",
|
|
toNodeId: "n-commercial-value",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description:
|
|
"The decision depends on commercial value definition.",
|
|
},
|
|
],
|
|
selectedQuestion: {
|
|
nodeId: "n-commercial-value",
|
|
question:
|
|
"How should commercial value be defined for this decision?",
|
|
reason: "Consequential unresolved uncertainty remains.",
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
|
|
const first = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
const second = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
|
|
expect(first.selectedQuestion.question).toBe(
|
|
second.selectedQuestion.question,
|
|
);
|
|
expect(first.selectedQuestion.reason).toBe(second.selectedQuestion.reason);
|
|
expect(first.diagnostics.unknownSelectionExplanation).toBeTruthy();
|
|
});
|
|
|
|
it("produces a validated update proposal for a valid request", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
stage: "proposal_ready",
|
|
proposal: makeProposal(),
|
|
diagnostics: {
|
|
promptVersion: "v0.4",
|
|
modelName: "configured",
|
|
nodeCount: 2,
|
|
edgeCount: 0,
|
|
validationStatus: "valid",
|
|
},
|
|
});
|
|
expect(provider.generateReconstruction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("valid request reaches prompt builder", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const buildGraphUpdatePrompt = vi.fn().mockReturnValue("PROMPT");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
|
|
const request = makeUpdateRequest();
|
|
const result = await updateCase(request, {
|
|
buildGraphUpdatePrompt,
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(buildGraphUpdatePrompt).toHaveBeenCalledWith({
|
|
situationGraph: request.situationGraph,
|
|
previousQuestion: request.previousQuestion,
|
|
answer: request.answer,
|
|
promptVersion: request.promptVersion,
|
|
});
|
|
expect(provider.generateReconstruction).toHaveBeenCalledWith(
|
|
"PROMPT",
|
|
"configured",
|
|
);
|
|
});
|
|
|
|
it("invalid request prevents provider call", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn(),
|
|
};
|
|
|
|
const result = await updateCase(
|
|
{ previousQuestion: "Q?", answer: "A" },
|
|
{
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
stage: "request_validation",
|
|
error: "Invalid update-case request",
|
|
statusCode: 400,
|
|
});
|
|
expect(result.validationErrors).toBeInstanceOf(Array);
|
|
expect(provider.generateReconstruction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("invalid graph prevents provider call", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn(),
|
|
};
|
|
|
|
const graph = makeUpdateGraph();
|
|
graph.nodes[0].dependsOn.push("missing-node");
|
|
|
|
const result = await updateCase(
|
|
makeUpdateRequest({ situationGraph: graph }),
|
|
{
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
stage: "graph_validation",
|
|
error: "Invalid situation graph",
|
|
statusCode: 400,
|
|
});
|
|
expect(result.graphValidationErrors).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('depends on "missing-node"'),
|
|
]),
|
|
);
|
|
expect(provider.generateReconstruction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prompt includes previous question and answer", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
const request = makeUpdateRequest();
|
|
|
|
await updateCase(request, {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
const prompt = provider.generateReconstruction.mock.calls[0][0];
|
|
expect(prompt).toContain(request.previousQuestion);
|
|
expect(prompt).toContain(request.answer);
|
|
});
|
|
|
|
it("returns proposal validation failure for malformed JSON", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue("{not json"),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
stage: "proposal_validation",
|
|
error: "Invalid graph update proposal",
|
|
diagnostics: {
|
|
promptVersion: "v0.4",
|
|
modelName: "configured",
|
|
},
|
|
statusCode: 502,
|
|
});
|
|
expect(result.proposalErrors).toBeInstanceOf(Array);
|
|
});
|
|
|
|
it("returns structured errors for schema-invalid proposal", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue({
|
|
updatedNodes: [{ nodeId: "n-unknown" }],
|
|
}),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stage).toBe("proposal_validation");
|
|
expect(result.proposalErrors).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: expect.any(Array),
|
|
message: expect.any(String),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("includes parser normalisations in diagnostics", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue({
|
|
updatedNodes: [],
|
|
}),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.diagnostics.normalisationsApplied).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
change: "Filled missing optional array with []",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("returns structured provider-stage failure", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi
|
|
.fn()
|
|
.mockRejectedValue(new Error("provider offline")),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
stage: "provider",
|
|
error: "Graph update proposal generation failed",
|
|
providerErrors: ["provider offline"],
|
|
statusCode: 502,
|
|
});
|
|
});
|
|
|
|
it("does not mutate the input graph", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
const request = makeUpdateRequest();
|
|
const originalGraph = JSON.parse(JSON.stringify(request.situationGraph));
|
|
|
|
await updateCase(request, {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(request.situationGraph).toEqual(originalGraph);
|
|
});
|
|
|
|
it("does not call applyGraphUpdate", async () => {
|
|
const utils = await import("@/lib/graph/utils.js");
|
|
const applySpy = vi.spyOn(utils, "applyGraphUpdate");
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
|
|
await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(applySpy).not.toHaveBeenCalled();
|
|
applySpy.mockRestore();
|
|
});
|
|
|
|
it("does not invent a next question outside the proposal", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
});
|
|
|
|
expect(result.selectedQuestion).toBeUndefined();
|
|
expect(result.nextQuestion).toBeUndefined();
|
|
expect(result.proposal.nextQuestion).toBeUndefined();
|
|
});
|
|
|
|
it("returns selectedQuestion from applied update proposal", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(
|
|
makeProposal({
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-build-decision",
|
|
label: "Build Confidence Engine decision",
|
|
description: "Decision introduced by the answer.",
|
|
kind: "state",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
}),
|
|
makeNode({
|
|
id: "n-commercial-value",
|
|
label: "Commercial value definition",
|
|
description:
|
|
"Need a concrete definition because the decision depends on it.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
],
|
|
addedEdges: [
|
|
{
|
|
id: "e-build-commercial-value",
|
|
fromNodeId: "n-build-decision",
|
|
toNodeId: "n-commercial-value",
|
|
relationship: "depends_on",
|
|
confidence: "medium",
|
|
description:
|
|
"The decision depends on commercial value definition.",
|
|
},
|
|
],
|
|
selectedQuestion: {
|
|
nodeId: "n-commercial-value",
|
|
question:
|
|
"How should commercial value be defined for this decision?",
|
|
reason: "Consequential unresolved uncertainty remains.",
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
|
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
|
expect(result.selectedQuestion?.question).not.toBe(
|
|
"How should commercial value be defined for this decision?",
|
|
);
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
|
"how should uncertainty regarding",
|
|
);
|
|
});
|
|
|
|
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("keeps a follow-up question when a resolved child still has an eligible sibling", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const initialGraph = makeCommercialUpdateGraph();
|
|
const firstPass = applyValidatedProposal({
|
|
situationGraph: initialGraph,
|
|
proposal: {
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-anchor",
|
|
label: "Update anchor",
|
|
description:
|
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
|
kind: "state",
|
|
status: "known",
|
|
confidence: "low",
|
|
}),
|
|
],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
},
|
|
});
|
|
|
|
expect(firstPass.success).toBe(true);
|
|
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue({
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: firstPass.selectedQuestion.nodeId,
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: null,
|
|
newValue:
|
|
"I experience it myself when deciding whether a project or investment is justified.",
|
|
reason: "The answer resolves the first child.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [firstPass.selectedQuestion.nodeId],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
}),
|
|
};
|
|
|
|
const result = await updateCase(
|
|
{
|
|
situationGraph: firstPass.updatedSituationGraph,
|
|
previousQuestion: firstPass.selectedQuestion.question,
|
|
answer:
|
|
"I experience it myself when deciding whether a project or investment is justified.",
|
|
promptVersion: "v0.4",
|
|
},
|
|
{
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
},
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion).toBeTruthy();
|
|
expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId);
|
|
expect(result.diagnostics.noQuestionReason).toBeNull();
|
|
});
|
|
|
|
it("defaults to proposal-only mode", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const applyValidatedProposal = vi.fn();
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
|
|
const result = await updateCase(makeUpdateRequest(), {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyValidatedProposal,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stage).toBe("proposal_ready");
|
|
expect(applyValidatedProposal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies the proposal only when explicitly enabled", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const request = makeUpdateRequest({
|
|
situationGraph: makeGraph({
|
|
centralStatement:
|
|
"Complaint counts increased while production also increased.",
|
|
nodes: [
|
|
makeNode({
|
|
id: "n-rate",
|
|
label: "Complaint rate",
|
|
description: "Need complaint rate",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
affects: ["n-conclusion"],
|
|
}),
|
|
makeNode({
|
|
id: "n-other-unknown",
|
|
label: "Other unknown",
|
|
description: "Another unresolved unknown",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
}),
|
|
makeNode({
|
|
id: "n-conclusion",
|
|
label: "Quality deterioration",
|
|
description: "Quality conclusion",
|
|
kind: "conclusion",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
dependsOn: ["n-rate"],
|
|
}),
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "n-rate",
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Initial summary",
|
|
}),
|
|
});
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue({
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n-rate",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: "2.0 complaints per 100 units",
|
|
newValue: "1.9 complaints per 100 units",
|
|
reason: "The answer provides the updated rate.",
|
|
},
|
|
{
|
|
nodeId: "n-conclusion",
|
|
previousStatus: "supported",
|
|
newStatus: "weakened",
|
|
previousValue: null,
|
|
newValue: null,
|
|
reason: "The updated rate weakens the conclusion.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n-rate"],
|
|
affectedNodeIds: ["n-conclusion"],
|
|
}),
|
|
};
|
|
|
|
const result = await updateCase(request, {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
stage: "update_applied",
|
|
affectedNodeIds: expect.arrayContaining(["n-rate", "n-conclusion"]),
|
|
resolvedUnknownNodeIds: ["n-rate"],
|
|
previousActiveUnknownNodeId: "n-rate",
|
|
newActiveUnknownNodeId: "n-other-unknown",
|
|
});
|
|
expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({
|
|
valid: true,
|
|
errors: [],
|
|
});
|
|
});
|
|
|
|
it("advances reasoning after comparability is resolved by the update answer", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
const provider = {
|
|
generateReconstruction: vi
|
|
.fn()
|
|
.mockResolvedValue(makeComparabilityProposal()),
|
|
};
|
|
|
|
const result = await updateCase(
|
|
{
|
|
situationGraph: makeComparabilityScenarioGraph(),
|
|
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.",
|
|
promptVersion: "v0.4",
|
|
},
|
|
{
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyProposal: true,
|
|
},
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.resolvedUnknownNodeIds).toEqual(["n-comparability-unknown"]);
|
|
expect(result.diagnostics).toMatchObject({
|
|
previousComparabilityStatus: "uncertain",
|
|
comparabilityStatus: "confirmed",
|
|
relationshipStatus: "potentially_related",
|
|
relationshipAssessed: true,
|
|
resolvedReasoningNodeIds: ["reasoning:comparability"],
|
|
emergentReasoningNodeCreated: true,
|
|
atomicityAssessment: "composite",
|
|
decompositionPerformed: true,
|
|
childUnknownCount: 5,
|
|
});
|
|
expect(result.diagnostics.emergentReasoningNodeId).toBeTruthy();
|
|
expect(result.diagnostics.childNodeIds).toHaveLength(5);
|
|
expect(result.diagnostics.atomicityReason).toBeTruthy();
|
|
expect(result.diagnostics.emergentReasoningNodeReason).toContain(
|
|
"backed by the graph",
|
|
);
|
|
expect(result.diagnostics.reasoningStagesBefore).toEqual([
|
|
{
|
|
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",
|
|
},
|
|
]);
|
|
expect(result.diagnostics.reasoningStagesAfter).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.",
|
|
},
|
|
]);
|
|
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
|
expect(result.selectedQuestion?.question).toBe(
|
|
"What evidence would clarify how the two observations were measured?",
|
|
);
|
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
|
/same basis|dso|receivables|debtor days|working capital/,
|
|
);
|
|
});
|
|
|
|
it("reselects the other-people sibling after the first commercial child is answered", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const seededGraph = applyValidatedProposal({
|
|
situationGraph: makeCommercialUpdateGraph(),
|
|
proposal: {
|
|
addedNodes: [
|
|
makeNode({
|
|
id: "n-anchor",
|
|
label: "Update anchor",
|
|
description:
|
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
|
kind: "state",
|
|
status: "known",
|
|
confidence: "low",
|
|
}),
|
|
],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
},
|
|
});
|
|
|
|
expect(seededGraph.success).toBe(true);
|
|
const resolvedFirstChildNodeId = seededGraph.selectedQuestion?.nodeId;
|
|
const resolvedFirstQuestion = seededGraph.selectedQuestion?.question;
|
|
|
|
const initial = await updateCase(
|
|
{
|
|
situationGraph: seededGraph.updatedSituationGraph,
|
|
previousQuestion: resolvedFirstQuestion,
|
|
answer:
|
|
"I experience it myself when I am trying to decide whether a project, idea or investment is justified, but I do not yet know how common that problem is for other people.",
|
|
promptVersion: "v0.4",
|
|
},
|
|
{
|
|
applyProposal: true,
|
|
config: MOCK_CONFIG,
|
|
provider: {
|
|
generateReconstruction: vi.fn().mockResolvedValue({
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: resolvedFirstChildNodeId,
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: null,
|
|
newValue:
|
|
"I experience it myself when I am trying to decide whether a project, idea or investment is justified, but I do not yet know how common that problem is for other people.",
|
|
reason: "The answer confirms a self-observed instance.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [resolvedFirstChildNodeId],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
}),
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(initial.success).toBe(true);
|
|
expect(initial.selectedQuestion?.nodeId).toBe(
|
|
initial.newActiveUnknownNodeId,
|
|
);
|
|
expect(initial.selectedQuestion?.question).toBe(
|
|
"What makes you think other people experience this problem too?",
|
|
);
|
|
expect(initial.selectedQuestion?.reasoningPattern).toBe("decision");
|
|
expect(initial.diagnostics.unresolvedCandidateCount).toBeGreaterThan(0);
|
|
expect(initial.diagnostics.eligibleCandidateCount).toBeGreaterThan(0);
|
|
expect(initial.diagnostics.candidateNodeIds).toContain(
|
|
initial.selectedQuestion?.nodeId,
|
|
);
|
|
expect(initial.diagnostics.resolvedCurrentTurnNodeIds).toContain(
|
|
resolvedFirstChildNodeId,
|
|
);
|
|
expect(initial.diagnostics.noQuestionReason).toBeNull();
|
|
expect(initial.diagnostics.reasoningPatternValidation).toMatchObject({
|
|
activePattern: "decision",
|
|
valid: true,
|
|
});
|
|
expect(initial.diagnostics.graphReasoningIntegrity).toBe("valid");
|
|
expect(initial.diagnostics.incompatibleNodeIds).toEqual([]);
|
|
expect(initial.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
|
/price|budget|market size|pilot metrics|benchmark|technical differentiation/,
|
|
);
|
|
});
|
|
|
|
// ── v0.49: post-authoritative finding-informed understanding seam test ──
|
|
|
|
it("post-authoritative findings modify summary but preserve authoritative graph result invariant", async () => {
|
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const mockGraph = makeGraph({
|
|
centralStatement: "Complaint counts increased while production also increased.",
|
|
nodes: [
|
|
makeNode({
|
|
id: "n-unknown",
|
|
label: "Complaint rate denominator",
|
|
description: "Need the denominator for the complaint rate",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "high",
|
|
}),
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "n-unknown",
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Nodes: 1 unknown | Edges: 0 total",
|
|
});
|
|
|
|
const mockApplicationResult = {
|
|
success: true,
|
|
updatedSituationGraph: mockGraph,
|
|
graphUpdate: { addedNodes: [], updatedNodes: [] },
|
|
selectedQuestion: null,
|
|
affectedNodeIds: [],
|
|
resolvedUnknownNodeIds: ["n-unknown"],
|
|
previousActiveUnknownNodeId: "n-unknown",
|
|
newActiveUnknownNodeId: null,
|
|
changesApplied: 1,
|
|
reasoningState: {
|
|
comparabilityStatus: "uncertain",
|
|
comparabilityReason: "deferred",
|
|
comparabilityEvidence: [],
|
|
relationshipStatus: "insufficient_information",
|
|
relationshipReason: "deferred",
|
|
relationshipAssessed: false,
|
|
contradictionReasoningAllowed: false,
|
|
reasoningStages: [],
|
|
},
|
|
previousReasoningState: null,
|
|
graphReferenceValidation: { valid: true, errors: [] },
|
|
diagnosticEvidence: {},
|
|
};
|
|
|
|
const applyValidatedProposal = vi.fn().mockReturnValue(mockApplicationResult);
|
|
const provider = {
|
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
|
};
|
|
|
|
// Control: no findings → summary should remain as authoritative gives
|
|
const controlRequest = makeUpdateRequest({ situationGraph: mockGraph });
|
|
const controlResult = await updateCase(controlRequest, {
|
|
provider,
|
|
config: MOCK_CONFIG,
|
|
applyValidatedProposal,
|
|
applyProposal: true,
|
|
});
|
|
|
|
// Treatment A: eligible null disposition Finding (schema expects "findings" key)
|
|
const treatmentAResult = await updateCase(
|
|
{ ...controlRequest, findings: [nullDispositionFinding()] },
|
|
{ provider, config: MOCK_CONFIG, applyValidatedProposal, applyProposal: true },
|
|
);
|
|
|
|
// Treatment B: not_relevant Finding (should NOT affect summary)
|
|
const treatmentBResult = await updateCase(
|
|
{ ...controlRequest, findings: [notRelevantFinding()] },
|
|
{ provider, config: MOCK_CONFIG, applyValidatedProposal, applyProposal: true },
|
|
);
|
|
|
|
// 1. Authoritative graph result is structurally identical (control vs treatment)
|
|
expect(controlResult.success).toBe(true);
|
|
expect(treatmentAResult.success).toBe(true);
|
|
expect(treatmentBResult.success).toBe(true);
|
|
|
|
expect(controlResult.updatedSituationGraph).toEqual(mockApplicationResult.updatedSituationGraph);
|
|
expect(treatmentAResult.updatedSituationGraph).toEqual(mockApplicationResult.updatedSituationGraph);
|
|
expect(treatmentBResult.updatedSituationGraph).toEqual(mockApplicationResult.updatedSituationGraph);
|
|
|
|
// 2. summary differs: control == authoritative, treatmentA has evidence appended
|
|
expect(controlResult.summary).toBe(mockGraph.currentSummary);
|
|
expect(treatmentAResult.summary).not.toBe(mockGraph.currentSummary);
|
|
expect(treatmentAResult.summary).toContain("Evidence");
|
|
expect(treatmentAResult.summary).toContain(
|
|
"Production volume increased by 12% in Q3.",
|
|
);
|
|
|
|
// 3. not_relevant Finding does NOT affect summary
|
|
expect(treatmentBResult.summary).toBe(mockGraph.currentSummary);
|
|
|
|
// 4. selectedQuestion / frontier fields are identical across all cases
|
|
expect(controlResult.selectedQuestion).toEqual(treatmentAResult.selectedQuestion);
|
|
expect(controlResult.selectedQuestion).toEqual(treatmentBResult.selectedQuestion);
|
|
expect(controlResult.resolvedUnknownNodeIds).toEqual(treatmentAResult.resolvedUnknownNodeIds);
|
|
expect(controlResult.resolvedUnknownNodeIds).toEqual(treatmentBResult.resolvedUnknownNodeIds);
|
|
|
|
// 5. authoritative stages and proposal are identical
|
|
expect(controlResult.stage).toBe(treatmentAResult.stage);
|
|
expect(controlResult.stage).toBe(treatmentBResult.stage);
|
|
expect(controlResult.proposal).toEqual(treatmentAResult.proposal);
|
|
expect(controlResult.proposal).toEqual(treatmentBResult.proposal);
|
|
|
|
// 6. Findings still never enter buildGraphUpdatePrompt (verify provider was called)
|
|
expect(provider.generateReconstruction).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("startCase no longer copies analysis nextQuestion directly when a graph-backed question exists", async () => {
|
|
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
|
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
|
|
|
const result = await startCase({ scenario: "Scenario text" });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.selectedQuestion).toMatchObject({
|
|
nodeId: result.situationGraph.activeUnknownNodeId,
|
|
question:
|
|
"What would clarify need the denominator for complaint rate in this situation?",
|
|
});
|
|
expect(result.diagnostics.reconstructionQuestion).toBe(
|
|
"What denominator is being used for the complaint rate?",
|
|
);
|
|
});
|
|
});
|