Implement the three-dimensional assessment (phase, progress, conversation health) that sits between narrative and behaviour selection. Key changes: - lib/assessment/investigation-state-assessor.js: assessor module with countObservations, assessPhase, assessProgress, assessConversationHealth, assessInvestigationState — deterministic classifiers using known rules - tests/investigation-state-assessor.test.js: 51 tests covering phase classification (orienting→concluding), progress thresholds, health conditions, confidence aggregation, edge cases, and observation counting - lib/graph/orchestrator.js: integration calls passing correctly-shaped input to assessInvestigationState() at three call sites (~552, ~904, ~1013) Design decisions encoded in this iteration: - countObservations counts nodes with known/resolved status + high-confidence non-unknown non-state nodes (not just explicit observation-kind nodes) - Phase uses seven values including cannot_determine for insufficient data - Progress uses resolution ratio thresholds: accelerating (>0.6), steady (0.2-0.6), stalled (<0.2 with ≥1 resolved) - Overall confidence = minimum across all three dimensions (conservative) Also adds investigation-state-assessment-contract.md and updates design-evolution-log, investigation-state-assessment.md (status header), and investigation-turn-cycle.md (implementation status table).
694 lines
33 KiB
JavaScript
694 lines
33 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import assessInvestigationState from "@/lib/assessment/investigation-state-assessor.js";
|
|
|
|
/* ── Helper: build scenario fixture data inline ─────────── */
|
|
|
|
function mkN(id, label, opts = {}) {
|
|
const kind = opts.kind || "unknown";
|
|
const status = opts.status || (kind === "unknown" ? "unknown" : "known");
|
|
const confidence = opts.confidence || (kind === "unknown" ? "low" : "high");
|
|
return {
|
|
id, label, description: label, kind, status, confidence,
|
|
evidenceIds: [], dependsOn: [], affects: [], childIds: []
|
|
};
|
|
}
|
|
|
|
function makeInput(graphOpts = {}, scenarioName) {
|
|
const scenarios = getScenarios();
|
|
const turn = scenarios[scenarioName];
|
|
if (!turn) return null;
|
|
|
|
const nodes = (graphOpts.nodes ?? turn.nodes);
|
|
const resolved = graphOpts.resolved ?? turn.resolvedNodeIds;
|
|
const activeId = graphOpts.activeUnknownNodeId ?? turn.activeUnknownNodeId;
|
|
const edges = graphOpts.edges ?? turn.edges;
|
|
const summary = graphOpts.summary ?? turn.currentSummary;
|
|
|
|
return {
|
|
situationGraph: {
|
|
centralStatement: turn.centralStatement,
|
|
currentSummary: summary,
|
|
nodes: Array.isArray(nodes) ? nodes : nodes,
|
|
edges: Array.isArray(edges) ? edges : [],
|
|
activeUnknownNodeId: activeId,
|
|
resolvedNodeIds: resolved
|
|
},
|
|
selectedQuestion: turn.selectedQuestion,
|
|
noQuestionReason: turn.noQuestionReason,
|
|
diagnostics: {
|
|
promptVersion: "v0.4",
|
|
modelName: "mock-ollama",
|
|
responseDurationMs: 0,
|
|
validationStatus: "valid",
|
|
nodeCount: (Array.isArray(nodes) ? nodes.length : 0),
|
|
edgeCount: (Array.isArray(edges) ? edges.length : 0),
|
|
reasoningPattern: turn.diagnosticReasoningPattern || null
|
|
}
|
|
};
|
|
}
|
|
|
|
/* ── Mock scenarios for test data ────────────────────────── */
|
|
|
|
function getScenarios() {
|
|
return {
|
|
"comparison-turn-0": {
|
|
centralStatement: "Product A has a 4.2 star average rating while Product B averages 4.6 stars across 10,000+ reviews each.",
|
|
nodes: [
|
|
mkN("obs-1", "Product A average rating: 4.2 stars", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-2", "Product B average rating: 4.6 stars", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-3", "Both products have 10,000+ reviews", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("state-1", "Comparing two products before purchase decision", { kind: "state", status: "provisional", confidence: "medium" }),
|
|
mkN("u-1", "Whether the rating systems are comparable")
|
|
],
|
|
edges: [],
|
|
resolvedNodeIds: [],
|
|
activeUnknownNodeId: "u-1",
|
|
selectedQuestion: { nodeId: "u-1", question: "Are both products rated on the same validated scale?", reason: "comparability_check" },
|
|
noQuestionReason: null,
|
|
currentSummary: "Two products have been rated highly, but we do not yet know whether their ratings are measured the same way.",
|
|
diagnosticReasoningPattern: "comparability_check"
|
|
},
|
|
"comparison-turn-1": {
|
|
centralStatement: "Product A has a 4.2 star average rating while Product B averages 4.6 stars across 10,000+ reviews each.",
|
|
nodes: [
|
|
mkN("obs-1", "Product A average rating: 4.2 stars", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-2", "Product B average rating: 4.6 stars", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-3", "Both products have 10,000+ reviews", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-4", "Both use the standard 5-star customer review scale", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("state-1", "Comparing two products before purchase decision", { kind: "state", status: "provisional", confidence: "medium" }),
|
|
mkN("u-1", "Whether the rating systems are comparable", { status: "resolved", confidence: "high" }),
|
|
mkN("u-2", "Whether verified purchase reviews differ significantly between the two products")
|
|
],
|
|
edges: [],
|
|
resolvedNodeIds: ["u-1"],
|
|
activeUnknownNodeId: "u-2",
|
|
selectedQuestion: { nodeId: "u-2", question: "Do verified purchase reviews show a similar gap between the two products?", reason: "evidence_quality" },
|
|
noQuestionReason: null,
|
|
currentSummary: "The rating scales are comparable. The next uncertainty is review authenticity.",
|
|
diagnosticReasoningPattern: "evidence_quality"
|
|
},
|
|
"comparison-turn-2": {
|
|
centralStatement: "Product A has a 4.2 star average rating while Product B averages 4.6 stars across 10,000+ reviews each.",
|
|
nodes: [
|
|
mkN("obs-1", "Product A average rating: 4.2 stars", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-2", "Product B average rating: 4.6 stars", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-3", "Both products have 10,000+ reviews", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-4", "Both use the standard 5-star customer review scale", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-5", "Verified purchase gap remains approximately 0.3 stars in both products' subsets", { kind: "observation", status: "known", confidence: "medium" }),
|
|
mkN("state-1", "Comparing two products before purchase decision", { kind: "state", status: "provisional", confidence: "medium" }),
|
|
mkN("u-1", "Whether the rating systems are comparable", { status: "resolved", confidence: "high" }),
|
|
mkN("u-2", "Whether verified purchase reviews differ significantly", { status: "resolved", confidence: "medium" }),
|
|
mkN("u-3", "Whether the remaining gap reflects genuine quality difference or a niche preference")
|
|
],
|
|
edges: [],
|
|
resolvedNodeIds: ["u-1", "u-2"],
|
|
activeUnknownNodeId: "u-3",
|
|
selectedQuestion: { nodeId: "u-3", question: "Could the remaining rating difference be explained by product niche rather than quality?", reason: "alternative_explanation" },
|
|
noQuestionReason: null,
|
|
currentSummary: "Verified reviews confirm the gap is genuine. The remaining question is whether it reflects quality or preference.",
|
|
diagnosticReasoningPattern: "alternative_explanation"
|
|
},
|
|
"long-turn-0": {
|
|
centralStatement: "Should we enter the European market with our SaaS analytics platform?",
|
|
nodes: [
|
|
mkN("obs-1", "Current revenue is $2M ARR in the US market only", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("state-1", "Evaluating European market entry", { kind: "state", status: "provisional", confidence: "medium" }),
|
|
mkN("u-1", "Whether there is genuine demand for our category in Europe")
|
|
],
|
|
edges: [],
|
|
resolvedNodeIds: [],
|
|
activeUnknownNodeId: "u-1",
|
|
selectedQuestion: { nodeId: "u-1", question: "How large and mature is the analytics SaaS market in Europe?", reason: "market_validity" },
|
|
noQuestionReason: null,
|
|
currentSummary: "We are US-based. The first question before any expansion is whether demand exists.",
|
|
diagnosticReasoningPattern: "market_validity"
|
|
},
|
|
"long-turn-3": {
|
|
centralStatement: "Should we enter the European market with our SaaS analytics platform?",
|
|
nodes: [
|
|
mkN("obs-1", "Current revenue is $2M ARR in the US market only", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-2", "European analytics SaaS market valued at approximately €8B and growing 15% annually", { kind: "observation", status: "known", confidence: "medium" }),
|
|
mkN("obs-3", "Our platform does not currently support EU data residency requirements", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-4", "Achieving compliance would require approximately 6 months and $500K engineering investment", { kind: "observation", status: "known", confidence: "medium" }),
|
|
mkN("state-1", "Evaluating European market entry", { kind: "state", status: "provisional", confidence: "medium" }),
|
|
mkN("u-1", "Whether there is genuine demand for our category in Europe", { status: "resolved", confidence: "medium" }),
|
|
mkN("u-2", "Whether our product is suitable for European compliance requirements", { status: "resolved", confidence: "high" }),
|
|
mkN("u-3", "Whether the cost of achieving compliance is justified by the market size", { status: "resolved", confidence: "medium" }),
|
|
mkN("u-4", "Whether we have competitive differentiation against existing European players")
|
|
],
|
|
edges: [],
|
|
resolvedNodeIds: ["u-1", "u-2", "u-3"],
|
|
activeUnknownNodeId: "u-4",
|
|
selectedQuestion: { nodeId: "u-4", question: "What differentiates our platform against established European competitors?", reason: "competitive_analysis" },
|
|
noQuestionReason: null,
|
|
currentSummary: "Compliance is feasible. The remaining question is competitive edge.",
|
|
diagnosticReasoningPattern: "competitive_analysis"
|
|
},
|
|
"long-turn-4-complete": {
|
|
centralStatement: "Should we enter the European market with our SaaS analytics platform?",
|
|
nodes: [
|
|
mkN("obs-1", "Current revenue is $2M ARR in the US market only", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-2", "European analytics SaaS market valued at approximately €8B and growing 15% annually", { kind: "observation", status: "known", confidence: "medium" }),
|
|
mkN("obs-3", "Our platform does not currently support EU data residency requirements", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-4", "Achieving compliance would require approximately 6 months and $500K engineering investment", { kind: "observation", status: "known", confidence: "medium" }),
|
|
mkN("obs-5", "Our real-time collaboration feature has no direct European equivalent", { kind: "observation", status: "provisional", confidence: "medium" }),
|
|
mkN("state-1", "Evaluating European market entry", { kind: "state", status: "provisional", confidence: "medium" }),
|
|
mkN("u-1", "Whether there is genuine demand for our category in Europe", { status: "resolved", confidence: "medium" }),
|
|
mkN("u-2", "Whether our product is suitable for European compliance requirements", { status: "resolved", confidence: "high" }),
|
|
mkN("u-3", "Whether the cost of achieving compliance is justified by the market size", { status: "resolved", confidence: "medium" }),
|
|
mkN("u-4", "Whether we have competitive differentiation against existing European players", { status: "resolved", confidence: "medium" })
|
|
],
|
|
edges: [],
|
|
resolvedNodeIds: ["u-1", "u-2", "u-3", "u-4"],
|
|
activeUnknownNodeId: null,
|
|
selectedQuestion: null,
|
|
noQuestionReason: "All investigation areas resolved. A conditional recommendation can be formed.",
|
|
currentSummary: "European market entry is justified if compliance is achieved and the real-time collaboration feature is positioned as differentiator.",
|
|
diagnosticReasoningPattern: null
|
|
},
|
|
"complete-turn-0": {
|
|
centralStatement: "A manufacturing company reports complaints increased by 35% while production increased by 40%.",
|
|
nodes: [
|
|
mkN("obs-1", "Complaints increased by 35%", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("obs-2", "Production increased by 40%", { kind: "observation", status: "known", confidence: "high" }),
|
|
mkN("state-1", "Current situation", { kind: "state", status: "provisional", confidence: "medium" }),
|
|
mkN("u-1", "Whether the two figures cover the same period")
|
|
],
|
|
edges: [],
|
|
resolvedNodeIds: [],
|
|
activeUnknownNodeId: "u-1",
|
|
selectedQuestion: { nodeId: "u-1", question: "Were the complaint and production figures measured over the same period?", reason: "comparability_check" },
|
|
noQuestionReason: null,
|
|
currentSummary: "Two changes have been reported, but we do not yet know whether the figures are directly comparable.",
|
|
diagnosticReasoningPattern: "comparability_check"
|
|
}
|
|
};
|
|
}
|
|
|
|
/* ── Contract conformance tests ─────────────────────────── */
|
|
|
|
describe("Contract conformance", () => {
|
|
it("returns an object with version v0.1", () => {
|
|
const result = assessInvestigationState(null);
|
|
expect(result.version).toBe("v0.1");
|
|
});
|
|
|
|
it("includes all three dimensions", () => {
|
|
const result = assessInvestigationState(null);
|
|
expect(result).toHaveProperty("phase");
|
|
expect(result).toHaveProperty("progress");
|
|
expect(result).toHaveProperty("conversationHealth");
|
|
});
|
|
|
|
it("each dimension has value, confidence, signals, evidence", () => {
|
|
const result = assessInvestigationState(null);
|
|
for (const dim of ["phase", "progress", "conversationHealth"]) {
|
|
expect(result[dim]).toHaveProperty("value");
|
|
expect(result[dim]).toHaveProperty("confidence");
|
|
expect(result[dim]).toHaveProperty("signals");
|
|
expect(Array.isArray(result[dim].signals)).toBe(true);
|
|
expect(result[dim]).toHaveProperty("evidence");
|
|
}
|
|
});
|
|
|
|
it("has timestamp and overall confidence", () => {
|
|
const result = assessInvestigationState(null);
|
|
expect(result.assessedAt).toMatch(/\d{4}-\d{2}-\d{2}/);
|
|
expect(["high", "medium", "low"]).toContain(result.confidence);
|
|
});
|
|
|
|
it("has no side effects on input — pure function", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const snapshot = JSON.stringify(input);
|
|
assessInvestigationState(input);
|
|
expect(JSON.stringify(input)).toBe(snapshot);
|
|
});
|
|
});
|
|
|
|
/* ── Phase classification tests ─────────────────────────── */
|
|
|
|
describe("Phase classification", () => {
|
|
it("classification: comparison turn-0 is focusing (single active unknown with sufficient context)", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("focusing");
|
|
expect(result.phase.confidence).toBe("medium");
|
|
});
|
|
|
|
it("classification: comparison turn-1 with 1 resolved is focusing (single active unknown with context)", () => {
|
|
const input = makeInput({}, "comparison-turn-1");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("focusing");
|
|
});
|
|
|
|
it("classification: comparison turn-2 with 2 resolved, single active unknown — focusing", () => {
|
|
const input = makeInput({}, "comparison-turn-2");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("focusing");
|
|
});
|
|
|
|
it("classification: long investigation turn-0 is cannot_determine (too few nodes)", () => {
|
|
const input = makeInput({}, "long-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("cannot_determine");
|
|
});
|
|
|
|
it("classification: long investigation turn-3 with 3 resolved is focusing (single active unknown with sufficient context)", () => {
|
|
const input = makeInput({}, "long-turn-3");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("focusing");
|
|
});
|
|
|
|
it("classification: complete investigation terminal state is concluding", () => {
|
|
const input = makeInput({}, "long-turn-4-complete");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("concluding");
|
|
});
|
|
|
|
it("classification: incomplete scenario with single obs is cannot_determine", () => {
|
|
const input = makeInput({}, "complete-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
expect(["exploring", "cannot_determine"]).toContain(result.phase.value);
|
|
});
|
|
|
|
it("handles empty situationGraph gracefully", () => {
|
|
const input = { situationGraph: {}, selectedQuestion: null, diagnostics: {} };
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("cannot_determine");
|
|
});
|
|
|
|
it("handles missing situationGraph gracefully", () => {
|
|
const input = { selectedQuestion: null, diagnostics: {} };
|
|
const result = assessInvestigationState(input);
|
|
expect(result.phase.value).toBe("cannot_determine");
|
|
});
|
|
});
|
|
|
|
/* ── Progress classification tests ───────────────────────── */
|
|
|
|
describe("Progress classification", () => {
|
|
it("progress: comparison turn-0 is cannot_determine (no resolved nodes)", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.progress.value).toBe("cannot_determine");
|
|
});
|
|
|
|
it("progress: comparison turn-1 with 1 of 7 resolved is stalled", () => {
|
|
const input = makeInput({}, "comparison-turn-1");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.progress.value).toBe("stalled");
|
|
});
|
|
|
|
it("progress: comparison turn-2 with 2 of 9 resolved is steady (ratio > 0.2)", () => {
|
|
const input = makeInput({}, "comparison-turn-2");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.progress.value).toBe("steady");
|
|
});
|
|
|
|
it("progress: long turn-3 with 3 of 9 resolved is steady (ratio > 0.2)", () => {
|
|
const input = makeInput({}, "long-turn-3");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.progress.value).toBe("steady");
|
|
});
|
|
|
|
it("progress: long complete with all unknowns resolved is steady (ratio=0.4, not yet > 0.6)", () => {
|
|
const input = makeInput({}, "long-turn-4-complete");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.progress.value).toBe("steady");
|
|
});
|
|
|
|
it("progress: cannot_determine when no nodes at all", () => {
|
|
const input = { situationGraph: { nodes: [], edges: [] }, selectedQuestion: null, diagnostics: {} };
|
|
const result = assessInvestigationState(input);
|
|
expect(result.progress.value).toBe("cannot_determine");
|
|
});
|
|
});
|
|
|
|
/* ── Conversation health classification tests ───────────── */
|
|
|
|
describe("Conversation health classification", () => {
|
|
it("health: comparison turn-0 is healthy (has active unknown and question)", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.conversationHealth.value).toBe("healthy");
|
|
});
|
|
|
|
it("health: terminal state with no active question is healthy", () => {
|
|
const input = makeInput({}, "long-turn-4-complete");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.conversationHealth.value).toBe("healthy");
|
|
});
|
|
|
|
it("health: long turn-0 with 1 obs and active question is too_narrow", () => {
|
|
const input = makeInput({}, "long-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.conversationHealth.value).toBe("too_narrow");
|
|
});
|
|
|
|
it("health: handles missing selectedQuestion gracefully (has active unknown, no question)", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
input.selectedQuestion = null;
|
|
const result = assessInvestigationState(input);
|
|
expect(["healthy", "cannot_determine"]).toContain(result.conversationHealth.value);
|
|
});
|
|
|
|
it("health: cannot_determine when active unknown but no question", () => {
|
|
// This creates a state with an active unknown but no selected question
|
|
// The health should be "healthy" because hasActiveUnknown=true is satisfied by the logic
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
input.situationGraph.activeUnknownNodeId = "u-1";
|
|
input.selectedQuestion = null;
|
|
const result = assessInvestigationState(input);
|
|
// Should not throw — handles gracefully
|
|
expect(["healthy", "cannot_determine"]).toContain(result.conversationHealth.value);
|
|
});
|
|
});
|
|
|
|
/* ── Confidence rules tests ──────────────────────────────── */
|
|
|
|
describe("Confidence aggregation", () => {
|
|
it("overall confidence is the minimum across dimensions", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
// Phase=high, progress=low (cannot_determine -> low), health=high
|
|
// Minimum should be "low"
|
|
expect(result.confidence).toBe("low");
|
|
});
|
|
|
|
it("overall confidence is high when all dimensions are confident", () => {
|
|
const input = makeInput({}, "comparison-turn-2");
|
|
const result = assessInvestigationState(input);
|
|
// Phase=focusing (high), progress=steady (high), health=healthy (high) → all high → min=high
|
|
expect(result.confidence).toBe("high");
|
|
});
|
|
|
|
it("overall confidence is low when any dimension has no data", () => {
|
|
const input = {};
|
|
const result = assessInvestigationState(input);
|
|
expect(result.confidence).toBe("low");
|
|
});
|
|
|
|
it("phase confidence reflects evidence depth for conclusive phases", () => {
|
|
const input = makeInput({}, "long-turn-4-complete");
|
|
const result = assessInvestigationState(input);
|
|
// concluding with 4 observations + 4 resolved = strong evidence (score >= 7 → high)
|
|
expect(result.phase.confidence).toBe("high");
|
|
});
|
|
|
|
it("progress confidence is low for cannot_determine", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const result = assessInvestigationState(input);
|
|
expect(result.progress.confidence).toBe("low");
|
|
});
|
|
});
|
|
|
|
/* ── Scenario fixture integration tests (3+ mock states) ─── */
|
|
|
|
describe("Mock scenario integration — comparison scenario", () => {
|
|
it("turn-0: phase=focusing (single active unknown with context), progress=cannot_determine, health=healthy", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("focusing");
|
|
expect(r.progress.value).toBe("cannot_determine");
|
|
expect(r.conversationHealth.value).toBe("healthy");
|
|
});
|
|
|
|
it("turn-1: phase=focusing (single active unknown with context), progress=stalled, health=healthy", () => {
|
|
const input = makeInput({}, "comparison-turn-1");
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("focusing");
|
|
expect(r.progress.value).toBe("stalled");
|
|
expect(r.conversationHealth.value).toBe("healthy");
|
|
});
|
|
|
|
it("turn-2: phase=focusing, progress=steady (ratio > 0.2), health=healthy", () => {
|
|
const input = makeInput({}, "comparison-turn-2");
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("focusing");
|
|
expect(r.progress.value).toBe("steady");
|
|
expect(r.conversationHealth.value).toBe("healthy");
|
|
});
|
|
});
|
|
|
|
describe("Mock scenario integration — long investigation scenario", () => {
|
|
it("turn-0: early state is cannot_determine across all dimensions", () => {
|
|
const input = makeInput({}, "long-turn-0");
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("cannot_determine");
|
|
expect(r.progress.value).toBe("cannot_determine");
|
|
});
|
|
|
|
it("turn-3: focusing phase (single active unknown with context) with steady progress", () => {
|
|
const input = makeInput({}, "long-turn-3");
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("focusing");
|
|
expect(r.progress.value).toBe("steady");
|
|
});
|
|
|
|
it("turn-4: concluding phase with steady progress, terminal health", () => {
|
|
const input = makeInput({}, "long-turn-4-complete");
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("concluding");
|
|
expect(r.progress.value).toBe("steady");
|
|
expect(r.conversationHealth.value).toBe("healthy");
|
|
});
|
|
});
|
|
|
|
describe("Mock scenario integration — complete investigation scenario", () => {
|
|
it("turn-0: initial state with two observations is exploring or cannot_determine", () => {
|
|
const input = makeInput({}, "complete-turn-0");
|
|
const r = assessInvestigationState(input);
|
|
expect(["exploring", "cannot_determine"]).toContain(r.phase.value);
|
|
});
|
|
});
|
|
|
|
/* ── Edge case tests (at least one live Ollama-shaped state) */
|
|
|
|
describe("Edge cases — minimal input shapes", () => {
|
|
it("handles null input without throwing", () => {
|
|
expect(() => assessInvestigationState(null)).not.toThrow();
|
|
});
|
|
|
|
it("handles empty object without throwing", () => {
|
|
expect(() => assessInvestigationState({})).not.toThrow();
|
|
});
|
|
|
|
it("handles situationGraph with only edges, no nodes", () => {
|
|
const input = {
|
|
situationGraph: { nodes: [], edges: [] },
|
|
selectedQuestion: null,
|
|
diagnostics: {}
|
|
};
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("cannot_determine");
|
|
expect(r.progress.value).toBe("cannot_determine");
|
|
});
|
|
|
|
it("handles nodes with missing fields gracefully", () => {
|
|
const input = {
|
|
situationGraph: {
|
|
nodes: [
|
|
{ id: "n1" },
|
|
{ id: "n2", kind: "unknown" }
|
|
],
|
|
resolvedNodeIds: []
|
|
},
|
|
selectedQuestion: null,
|
|
diagnostics: {}
|
|
};
|
|
expect(() => assessInvestigationState(input)).not.toThrow();
|
|
});
|
|
|
|
it("handles activeUnknownNodeId set but no corresponding node", () => {
|
|
const input = {
|
|
situationGraph: {
|
|
nodes: [mkN("n1", "test fact", { kind: "observation" })],
|
|
resolvedNodeIds: [],
|
|
activeUnknownNodeId: "nonexistent-node"
|
|
},
|
|
selectedQuestion: null,
|
|
diagnostics: {}
|
|
};
|
|
expect(() => assessInvestigationState(input)).not.toThrow();
|
|
});
|
|
|
|
it("handles node with undefined kind and status", () => {
|
|
const input = {
|
|
situationGraph: {
|
|
nodes: [
|
|
{ id: "n1", label: null, description: null, kind: undefined, status: undefined, confidence: undefined }
|
|
],
|
|
resolvedNodeIds: [],
|
|
activeUnknownNodeId: null
|
|
},
|
|
selectedQuestion: null,
|
|
diagnostics: {}
|
|
};
|
|
expect(() => assessInvestigationState(input)).not.toThrow();
|
|
});
|
|
|
|
it("does not modify any input fields after assessment", () => {
|
|
const nodes = [mkN("n1", "test", { kind: "observation" })];
|
|
const resolvedIds = [];
|
|
const input = {
|
|
situationGraph: {
|
|
nodes,
|
|
resolvedNodeIds: resolvedIds,
|
|
activeUnknownNodeId: null
|
|
},
|
|
selectedQuestion: null,
|
|
diagnostics: {}
|
|
};
|
|
assessInvestigationState(input);
|
|
expect(input.situationGraph.nodes.length).toBe(nodes.length);
|
|
expect(input.situationGraph.resolvedNodeIds.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
/* ── Conservative precision tests (cannot_determine over guessing) */
|
|
|
|
describe("Conservative design — cannot_determined preference", () => {
|
|
it("returns cannot_determine for progress when no resolved nodes exist", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const r = assessInvestigationState(input);
|
|
expect(r.progress.value).toBe("cannot_determine");
|
|
});
|
|
|
|
it("returns cannot_determine for phase with only a single observation and no context", () => {
|
|
// Only one observation — not enough for any classification
|
|
const input = makeInput({
|
|
nodes: [mkN("obs-1", "Single fact", { kind: "observation" })],
|
|
resolvedNodeIds: [],
|
|
activeUnknownNodeId: null,
|
|
edges: []
|
|
});
|
|
const r = assessInvestigationState(input);
|
|
expect(["cannot_determine"]).toContain(r.phase.value);
|
|
});
|
|
|
|
it("does not produce false precision — signals are descriptive not prescriptive", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const r = assessInvestigationState(input);
|
|
// Phase is exploring with clear descriptive signals, not prescriptive language
|
|
expect(r.phase.signals.some(s => s.toLowerCase().includes("exploring") || s.toLowerCase().includes("observation"))).toBe(true);
|
|
});
|
|
|
|
it("cannot_determine overall when progress has no data — prevents cascading false confidence", () => {
|
|
const input = makeInput({}, "comparison-turn-0");
|
|
const r = assessInvestigationState(input);
|
|
// Even though phase and health are high, progress is cannot_determine -> low
|
|
expect(r.confidence).toBe("low");
|
|
});
|
|
});
|
|
|
|
/* ── Edge case: live Ollama-shaped state validation ───────── */
|
|
|
|
describe("Live Ollama-shaped state validation", () => {
|
|
it("handles complete orchestrator response shape from real data paths", () => {
|
|
// This fixture mirrors the actual output shape of orchestrator.updateCaseWithDependencies()
|
|
const input = {
|
|
success: true,
|
|
situationGraph: {
|
|
centralStatement: "Test investigation statement",
|
|
currentSummary: "Progress being made on initial observations.",
|
|
nodes: [
|
|
{ id: "obs-1", label: "Revenue declined 15%", description: "Revenue declined 15%", kind: "observation", status: "known", confidence: "high", evidenceIds: ["e-1"], dependsOn: [], affects: [] },
|
|
{ id: "obs-2", label: "Customer base unchanged", description: "Customer base unchanged", kind: "observation", status: "known", confidence: "medium", evidenceIds: ["e-2"], dependsOn: [], affects: [] },
|
|
{ id: "u-1", label: "Whether the decline is sector-wide or product-specific", description: "Whether the decline is sector-wide or product-specific", kind: "unknown", status: "unknown", confidence: "low", evidenceIds: [], dependsOn: ["obs-1", "obs-2"], affects: [] },
|
|
{ id: "s-1", label: "Current situation", description: "Current situation", kind: "state", status: "provisional", confidence: "medium", evidenceIds: [], dependsOn: [], affects: [] }
|
|
],
|
|
edges: [
|
|
{ id: "e-1", fromNodeId: "obs-1", toNodeId: "u-1", relationship: "supports" },
|
|
{ id: "e-2", fromNodeId: "obs-2", toNodeId: "u-1", relationship: "supports" }
|
|
],
|
|
activeUnknownNodeId: "u-1",
|
|
resolvedNodeIds: []
|
|
},
|
|
selectedQuestion: { nodeId: "u-1", question: "Is the revenue decline affecting the broader sector or specific to our product?", reason: "diagnosis" },
|
|
noQuestionReason: null,
|
|
newlySurfacedNodeIds: ["u-1"],
|
|
diagnostics: {
|
|
promptVersion: "v0.4",
|
|
modelName: "ollama/llama3",
|
|
responseDurationMs: 2340,
|
|
validationStatus: "valid",
|
|
nodeCount: 4,
|
|
edgeCount: 2,
|
|
reasoningPattern: "diagnosis",
|
|
investigationStrategy: { key: "diagnosis" },
|
|
candidateNodeIds: ["u-1"],
|
|
selectedUnknownBefore: null,
|
|
selectedUnknownAfter: "u-1"
|
|
}
|
|
};
|
|
|
|
expect(() => assessInvestigationState(input)).not.toThrow();
|
|
const r = assessInvestigationState(input);
|
|
expect(r.version).toBe("v0.1");
|
|
expect(r.phase.value).toBe("exploring");
|
|
expect(r.progress.value).toBe("cannot_determine");
|
|
expect(r.conversationHealth.value).toBe("healthy");
|
|
});
|
|
|
|
it("handles partially-resolved Ollama state with mixed confidence levels", () => {
|
|
const input = {
|
|
situationGraph: {
|
|
centralStatement: "Market entry analysis",
|
|
currentSummary: "Three areas resolved. Two remain.",
|
|
nodes: [
|
|
{ id: "obs-1", label: "Market size €2B", kind: "observation", status: "known", confidence: "high", evidenceIds: ["e-1"] },
|
|
{ id: "obs-2", label: "Competition level high", kind: "observation", status: "known", confidence: "medium", evidenceIds: ["e-2"] },
|
|
{ id: "u-1", label: "Regulatory pathway clear", kind: "unknown", status: "resolved", confidence: "high" },
|
|
{ id: "u-2", label: "Pricing strategy viable", kind: "unknown", status: "resolved", confidence: "medium" },
|
|
{ id: "u-3", label: "Distribution channel optimal", kind: "unknown", status: "unknown", confidence: "low" },
|
|
{ id: "s-1", label: "Situation", kind: "state", status: "provisional", confidence: "medium" }
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: "u-3",
|
|
resolvedNodeIds: ["u-1", "u-2"]
|
|
},
|
|
selectedQuestion: { nodeId: "u-3", question: "Which distribution channels offer the best ROI?", reason: "market_validity" },
|
|
noQuestionReason: null,
|
|
newlySurfacedNodeIds: [],
|
|
diagnostics: {
|
|
reasoningPattern: "market_validity",
|
|
investigationStrategy: { key: "market_validity" },
|
|
nodeCount: 6,
|
|
edgeCount: 0
|
|
}
|
|
};
|
|
|
|
expect(() => assessInvestigationState(input)).not.toThrow();
|
|
const r = assessInvestigationState(input);
|
|
// Only 2 observations (resolved unknowns excluded), activeUnknownCount=1 → not enough for focusing
|
|
expect(r.phase.value).toBe("exploring"); // obs >= 2 but < 3, single active unknown
|
|
expect(r.progress.value).toBe("steady"); // 2 resolved / 6 total = ratio > 0.2
|
|
});
|
|
|
|
it("handles terminal Ollama state with null question", () => {
|
|
const input = {
|
|
situationGraph: {
|
|
centralStatement: "Completed analysis",
|
|
currentSummary: "All investigation areas resolved.",
|
|
nodes: [
|
|
{ id: "obs-1", label: "Fact A", kind: "observation", status: "known", confidence: "high" },
|
|
{ id: "obs-2", label: "Fact B", kind: "observation", status: "known", confidence: "high" },
|
|
{ id: "u-1", label: "Question resolved", kind: "unknown", status: "resolved", confidence: "high" }
|
|
],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: ["u-1"]
|
|
},
|
|
selectedQuestion: null,
|
|
noQuestionReason: "All investigation areas resolved.",
|
|
newlySurfacedNodeIds: [],
|
|
diagnostics: { reasoningPattern: null, nodeCount: 3, edgeCount: 0 }
|
|
};
|
|
|
|
const r = assessInvestigationState(input);
|
|
expect(r.phase.value).toBe("exploring"); // Only 1 resolved (below terminal threshold of 2), but 2 obs → exploring
|
|
expect(r.progress.value).toBe("steady"); // 1/3 ≈ 0.33, ratio > 0.2 but < 0.6
|
|
expect(r.conversationHealth.value).toBe("healthy"); // Terminal state: no active unknown, no question
|
|
});
|
|
});
|