/** * Investigation State Assessment — Experiment 18 First Executable Slice * * Pure deterministic function that evaluates investigation state across * three dimensions: phase, progress, and conversation health. * * Conservative by design: prefers cannot_determine over invented precision. * Safe with missing fields — returns cannot_determine for any dimension * whose data is insufficient rather than guessing. * * Contract reference: docs/investigation-state-assessment-contract.md */ /* ── Helpers ─────────────────────────────────────────────── */ /** * Normalise resolvedNodeIds from the fixture format ({ resolved: [...] }) * or from orchestrator format (resolvedNodeIds directly). */ function getResolvedIds(input) { const fromGraph = input.situationGraph?.resolvedNodeIds; if (Array.isArray(fromGraph)) return fromGraph; // Legacy scenario fixture shape const fromResolved = input.resolved; if (Array.isArray(fromResolved)) return fromResolved; return []; } /** * Normalise the activeUnknownNodeId across formats. */ function getActiveUnknownId(input) { const fromGraph = input.situationGraph?.activeUnknownNodeId; if (fromGraph !== undefined && fromGraph !== null) return fromGraph; const fromScenario = input.active; if (fromScenario !== undefined && fromScenario !== null) return fromScenario; return null; } /** * Count resolved nodes — either via the explicit array or by checking * per-node status === "resolved". */ function countResolved(input, nodes) { const resolvedIds = getResolvedIds(input); if (resolvedIds.length > 0) { return nodes.filter(n => n && resolvedIds.includes(n.id)).length; } // Fallback: count nodes with status === "resolved" return nodes.filter(n => n && n.status === "resolved").length; } /** * Classify node confidence as a normalised score for comparison. */ function confidenceScore(confidence) { if (!confidence) return 0; const map = { low: 1, medium: 2, high: 3 }; return map[confidence] ?? 0; } /** * Normalise confidence label from score. */ function scoreToConfidence(score) { if (score >= 7) return "high"; if (score >= 3) return "medium"; return "low"; } /** * Count observations: explicit observation kind with known/resolved status, * or high-confidence evidence nodes. Deliberately excludes scaffolding state * nodes and already-resolved unknowns (they have their own assessment). */ function countObservations(nodes, resolvedIds) { if (!Array.isArray(nodes)) return 0; const resolvedSet = new Set(resolvedIds); let count = 0; for (const node of nodes) { if (!node || typeof node.kind !== "string") continue; // Skip already-resolved unknowns — their resolution is tracked separately if (resolvedSet.has(node.id)) continue; // Include explicit observation kind with known/resolved status if (node.kind === "observation" && (node.status === "known" || node.status === "resolved")) { count++; continue; } // Include non-unknown nodes with high confidence that aren't scaffolding states if (confidenceScore(node.confidence) >= 3 && node.kind !== "state") { count++; continue; } } return count; } /** * Count total active (non-resolved) unknowns. */ function countActiveUnknowns(input, nodes, resolvedIds) { const activeId = getActiveUnknownId(input); if (!Array.isArray(nodes)) return activeId ? 1 : 0; // Nodes explicitly marked as "unknown" kind that are not resolved let count = 0; for (const node of nodes) { if (!node || node.kind !== "unknown") continue; const isResolved = resolvedIds.includes(node.id) || node.status === "resolved"; if (!isResolved) count++; } // Fallback: if no unknown-kinded nodes and we have an active ID, // the active node itself counts as an active unknown if (count === 0 && activeId) { const isActiveNode = nodes.find(n => n && n.id === activeId); if (!isActiveNode || isActiveNode.status !== "resolved") count = 1; } return count; } /** * Compute resolution ratio: resolved / total non-empty nodes. */ function computeResolutionRatio(resolvedCount, totalNodes) { if (totalNodes <= 0 || resolvedCount === 0) return null; return resolvedCount / totalNodes; } /** * Count distinct reasoning patterns from selected question or diagnostics. */ function getReasoningPatterns(input) { const patterns = []; // From selectedQuestion.reason (may contain reasoning pattern keyword) if (input.selectedQuestion?.reasoningPattern) { patterns.push(input.selectedQuestion.reasoningPattern); } // From diagnostics const diag = input.diagnostics || {}; if (diag.reasoningPattern && !patterns.includes(diag.reasoningPattern)) { patterns.push(diag.reasoningPattern); } if (diag.investigationStrategy?.key && !patterns.includes(diag.investigationStrategy.key)) { patterns.push(diag.investigationStrategy.key); } return patterns; } /** * Count edges connected to each node for structural analysis. */ function countEdgeConnections(nodes, edges) { if (!Array.isArray(edges)) return {}; const counts = {}; for (const edge of edges) { if (!edge || !edge.fromNodeId || !edge.toNodeId) continue; counts[edge.fromNodeId] = (counts[edge.fromNodeId] ?? 0) + 1; counts[edge.toNodeId] = (counts[edge.toNodeId] ?? 0) + 1; } return counts; } /** * Determine the minimum confidence across all dimensions. */ function minConfidence(...confidences) { const priority = { high: 3, medium: 2, low: 1, cannot_determine: 0 }; let minScore = 4; let result = "high"; for (const c of confidences) { const s = priority[c] ?? 4; if (s < minScore) { minScore = s; result = c; } } return result; } /* ── Phase Classification ────────────────────────────────── */ function assessPhase(input) { const resolvedIds = getResolvedIds(input); const nodes = input.situationGraph?.nodes || []; const totalNodes = Array.isArray(nodes) ? nodes.length : 0; const resolvedCount = countResolved(input, nodes); const activeUnknownCount = countActiveUnknowns(input, nodes, resolvedIds); const observations = countObservations(nodes, resolvedIds); const ratio = computeResolutionRatio(resolvedCount, totalNodes); const hasQuestion = Boolean(input.selectedQuestion && input.selectedQuestion.nodeId); const activeUnknownId = getActiveUnknownId(input); // Terminal: no active unknowns + sufficient history + no current question if (activeUnknownCount === 0 && resolvedCount >= 2 && !hasQuestion) { return { value: "concluding", confidence: scoreToConfidence(observations * 2 + resolvedCount), signals: [ `All investigation areas resolved (${resolvedCount} items)`, `No active question — investigation complete` ], evidence: { resolvedNodeCount: resolvedCount, activeUnknownCount: 0, unknownResolutionRatio: ratio, observationDensity: observations, evidenceDepth: observations >= 4 ? "deep" : observations >= 2 ? "moderate" : "shallow" } }; } // Synthesising: near-completion with majority resolved if (activeUnknownCount <= 1 && ratio !== null && ratio > 0.5) { return { value: "synthesising", confidence: scoreToConfidence(observations * 2 + resolvedCount), signals: [ `Near completion: ${resolvedCount} of ${totalNodes} resolved`, `Resolution ratio: ${(ratio * 100).toFixed(0)}%` ], evidence: { resolvedNodeCount: resolvedCount, activeUnknownCount, unknownResolutionRatio: ratio, observationDensity: observations, evidenceDepth: observations >= 4 ? "deep" : observations >= 2 ? "moderate" : "shallow" } }; } // Focusing: single remaining unknown with sufficient context if (activeUnknownCount === 1 && observations >= 3) { return { value: "focusing", confidence: scoreToConfidence(observations * 2 + resolvedCount), signals: [ `Single active unknown: ${activeUnknownId ?? "unspecified"}`, `${observations} established observations provide sufficient context` ], evidence: { resolvedNodeCount: resolvedCount, activeUnknownCount, unknownResolutionRatio: ratio, observationDensity: observations, evidenceDepth: observations >= 4 ? "deep" : "moderate" } }; } // Exploring: gathering initial evidence — multiple observations but low resolution if (observations >= 2 && (ratio === null || ratio < 0.4)) { return { value: "exploring", confidence: scoreToConfidence(observations + resolvedCount), signals: [ `${observations} initial observations gathered`, `Resolution progress low (${resolvedCount}/${totalNodes} or unknown)` ], evidence: { resolvedNodeCount: resolvedCount, activeUnknownCount, unknownResolutionRatio: ratio, observationDensity: observations, evidenceDepth: "shallow" } }; } // Deepening: structured investigation with remaining unknowns if (activeUnknownCount > 1 && resolvedCount >= 3) { return { value: "deepening", confidence: scoreToConfidence(resolvedCount + observations), signals: [ `Structured investigation in progress`, `${resolvedCount} resolved, ${activeUnknownCount} active unknowns remaining` ], evidence: { resolvedNodeCount: resolvedCount, activeUnknownCount, unknownResolutionRatio: ratio, observationDensity: observations, evidenceDepth: observations >= 4 ? "deep" : "moderate" } }; } // Cannot determine — insufficient data return { value: "cannot_determine", confidence: "low", signals: [ `Insufficient data for phase classification`, `Total nodes: ${totalNodes}, resolved: ${resolvedCount}, active: ${activeUnknownCount}` ], evidence: { resolvedNodeCount: resolvedCount, activeUnknownCount, unknownResolutionRatio: ratio, observationDensity: observations ?? 0, evidenceDepth: totalNodes < 3 ? "insufficient" : "shallow" } }; } /* ── Progress Classification ─────────────────────────────── */ function assessProgress(input) { const resolvedIds = getResolvedIds(input); const nodes = input.situationGraph?.nodes || []; const totalNodes = Array.isArray(nodes) ? nodes.length : 0; const resolvedCount = countResolved(input, nodes); const ratio = computeResolutionRatio(resolvedCount, totalNodes); // No data at all — cannot determine if (totalNodes <= 2 || resolvedCount === 0) { return { value: "cannot_determine", confidence: "low", signals: [ `Insufficient data for progress assessment`, `Total nodes: ${totalNodes}, resolved: ${resolvedCount}` ], evidence: { turnCount: 0, recentResolutionsLastTurn: 0, newUnknownsPerTurn: null, repeatedNodeIds: [] } }; } // Accelerating: resolving faster than accumulating — high ratio if (ratio !== null && ratio > 0.6) { return { value: "accelerating", confidence: scoreToConfidence(resolvedCount * 2 + totalNodes), signals: [ `High resolution progress: ${(ratio * 100).toFixed(0)}% of nodes resolved`, `${resolvedCount} of ${totalNodes} nodes resolved` ], evidence: { turnCount: Math.floor(totalNodes / 3), // approximation per scenario pattern recentResolutionsLastTurn: resolvedCount, newUnknownsPerTurn: null, repeatedNodeIds: [] } }; } // Steady: moderate progress — ratio between 0.2 and 0.6 if (ratio !== null && ratio >= 0.2) { return { value: "steady", confidence: scoreToConfidence(resolvedCount + totalNodes), signals: [ `Moderate resolution progress: ${(ratio * 100).toFixed(0)}% of nodes resolved`, `${resolvedCount} of ${totalNodes} nodes resolved` ], evidence: { turnCount: Math.floor(totalNodes / 3), recentResolutionsLastTurn: resolvedCount, newUnknownsPerTurn: null, repeatedNodeIds: [] } }; } // Stalled: some work done but insufficient momentum if (resolvedCount >= 1) { return { value: "stalled", confidence: scoreToConfidence(resolvedCount + totalNodes), signals: [ `Low resolution progress: ${(ratio !== null ? (ratio * 100).toFixed(0) : "<10")}% of nodes resolved`, `${resolvedCount} of ${totalNodes} nodes resolved — insufficient momentum` ], evidence: { turnCount: Math.floor(totalNodes / 3), recentResolutionsLastTurn: resolvedCount, newUnknownsPerTurn: null, repeatedNodeIds: [] } }; } // Cannot determine (safety net) return { value: "cannot_determine", confidence: "low", signals: [ `Cannot classify progress with available data`, `Total nodes: ${totalNodes}, resolved: ${resolvedCount}` ], evidence: { turnCount: 0, recentResolutionsLastTurn: 0, newUnknownsPerTurn: null, repeatedNodeIds: [] } }; } /* ── Conversation Health Classification ──────────────────── */ function assessConversationHealth(input) { const resolvedIds = getResolvedIds(input); const nodes = input.situationGraph?.nodes || []; const totalNodes = Array.isArray(nodes) ? nodes.length : 0; const observations = countObservations(nodes, resolvedIds); const activeUnknownCount = countActiveUnknowns(input, nodes, resolvedIds); const hasQuestion = Boolean(input.selectedQuestion && input.selectedQuestion.nodeId); const hasActiveUnknown = activeUnknownCount > 0; const ratio = computeResolutionRatio(countResolved(input, nodes), totalNodes); // Terminal state with all resolved — healthy (closed loop) if (!hasActiveUnknown && !hasQuestion) { return { value: "healthy", confidence: scoreToConfidence(observations + countResolved(input, nodes)), signals: ["Investigation closed — no active question or unknowns"], evidence: { questionTypeDistribution: null, activeUnknownCount: 0, resolvedNodeRatio: ratio, hasActiveQuestion: false, summaryLength: (input.situationGraph?.currentSummary || "").length } }; } // Too broad: multiple unresolved unknowns without sufficient resolved context if (activeUnknownCount > 3 && countResolved(input, nodes) < 2) { return { value: "too_broad", confidence: scoreToConfidence(totalNodes), signals: [ `${activeUnknownCount} active unknowns with fewer than 2 resolved items`, `Investigation may be spreading too thin` ], evidence: { questionTypeDistribution: null, activeUnknownCount, resolvedNodeRatio: ratio, hasActiveQuestion: hasQuestion, summaryLength: (input.situationGraph?.currentSummary || "").length } }; } // Too narrow: asking a question without sufficient context if (observations <= 1 && hasQuestion) { return { value: "too_narrow", confidence: "low", signals: [ `Only ${observations} observation(s) available before active question`, `Asking requires more contextual evidence` ], evidence: { questionTypeDistribution: null, activeUnknownCount, resolvedNodeRatio: ratio, hasActiveQuestion: true, summaryLength: (input.situationGraph?.currentSummary || "").length } }; } // Healthy: active investigation with open questions and balanced state if (hasActiveUnknown && hasQuestion) { return { value: "healthy", confidence: scoreToConfidence(observations + countResolved(input, nodes)), signals: [ `Active investigation in progress: ${activeUnknownCount} unresolved unknown(s)`, `Question actively driving the investigation forward` ], evidence: { questionTypeDistribution: null, activeUnknownCount, resolvedNodeRatio: ratio, hasActiveQuestion: true, summaryLength: (input.situationGraph?.currentSummary || "").length } }; } // Cannot determine — safety net return { value: "cannot_determine", confidence: "low", signals: [ `Insufficient conversation signals to evaluate health`, `activeUnknowns: ${activeUnknownCount}, hasQuestion: ${hasQuestion}, observations: ${observations}` ], evidence: { questionTypeDistribution: null, activeUnknownCount, resolvedNodeRatio: ratio, hasActiveQuestion: hasQuestion, summaryLength: (input.situationGraph?.currentSummary || "").length } }; } /* ── Main Assessor Function ──────────────────────────────── */ /** * Assess investigation state across three deterministic dimensions. * * This is a pure function with no side effects, no network calls, and no * mutation of input state. It handles missing or partial data gracefully * by returning cannot_determine for any dimension whose evidence is * insufficient rather than guessing. * * @param {Object} input — Investigation state from orchestrator or scenario fixture * @param {Object} [input.situationGraph] — Graph with nodes, edges, activeUnknownNodeId, resolvedNodeIds * @param {Object[]} [input.situationGraph.nodes] — Node array * @param {string[]} [input.situationGraph.resolvedNodeIds] — Resolved node ID strings * @param {string|null} [input.situationGraph.activeUnknownNodeId] — Currently targeted unknown * @param {Object|null} [input.selectedQuestion] — Current question { nodeId, question, reason } * @param {Object} [input.diagnostics] — Turn diagnostics with reasoningPattern, nodeCount, etc. * @param {string|null} [input.noQuestionReason] — Why no question was selected * @returns {{version: string, assessedAt: string, confidence: string, phase: Object, progress: Object, conversationHealth: Object}} */ export function assessInvestigationState(input) { if (!input) { return { version: "v0.1", assessedAt: new Date().toISOString(), confidence: "low", phase: { value: "cannot_determine", confidence: "low", signals: ["No input provided"], evidence: {} }, progress: { value: "cannot_determine", confidence: "low", signals: ["No input provided"], evidence: {} }, conversationHealth: { value: "cannot_determine", confidence: "low", signals: ["No input provided"], evidence: {} } }; } const phase = assessPhase(input); const progress = assessProgress(input); const health = assessConversationHealth(input); const overallConfidence = minConfidence(phase.confidence, progress.confidence, health.confidence); return { version: "v0.1", assessedAt: new Date().toISOString(), confidence: overallConfidence, phase, progress, conversationHealth: health }; } export default assessInvestigationState;