/** * Canonical live-update experiment harness. * * Loads .env.local, validates Ollama configuration, invokes updateCase() * through the real production path, and captures the standard reasoning * checkpoints needed by reasoning experiments. * * Usage (from a test file): * const { runLiveExperiment } = require("./tests/graph/live-update-experiment-helper.cjs"); * * const result = await runLiveExperiment({ * graph: /* SituationGraph fixture *\/, * previousQuestion: "Is risk a hard constraint?", * answer: "Risk matters more to me.", * }); */ const dotenv = require("dotenv"); dotenv.config({ path: ".env.local" }); // Re-export production helper for apparatus use — avoids fabricating Finding propositions. const { deriveFindingsFromContributions } = require("../../lib/graph/finding-helpers.js"); function assertRequiredEnv(name) { const value = process.env[name]; if (!value) { throw new Error( `Live experiment requires ${name}. Set it in .env.local.\n` + `Found: OLLAMA_BASE_URL=${process.env.OLLAMA_BASE_URL ?? "(missing)"}, ` + `OLLAMA_MODEL=${process.env.OLLAMA_MODEL ?? "(missing)"}` ); } return value; } /** * Extract checkpoint data from a raw updateCase() result object. * * Pure function — no side effects, no I/O. Intended for use both by * `runLiveExperiment()` (apparatus wrapper) and by deterministic tests * that pass mock results directly. * * @param {object} result — raw result from updateCase() * @param {object} params — { graph?, answer? } * - graph: SituationGraph input fixture (for findingsCount total derived from input) * - answer: previous question text for deriveCategory fallback * @returns {object} checkpoint object per spec contract */ function extractLiveExperimentCheckpoint(result, params) { const answerMeaning = result?.proposal?.answerMeaning || {}; // Graph structural checkpoint (available from updatedSituationGraph only when present) const updatedGraph = result?.updatedSituationGraph; const graphCheckpoint = updatedGraph ? { nodeCount: updatedGraph.nodes?.length ?? 0, edgeCount: updatedGraph.edges?.length ?? 0, resolvedNodeIds: updatedGraph.resolvedNodeIds ?? [], } : null; return { // ── timing (available via wrapper) ──────────────────── modelElapsedMs: result?.modelElapsedMs !== undefined ? result.modelElapsedMs : null, endToEndElapsedMs: result?.endToEndElapsedMs !== undefined ? result.endToEndElapsedMs : null, providerGenerateCalls: result != null && result.providerGenerateCalls != null ? result.providerGenerateCalls : 0, modelName: result?.modelName || null, // ── answer meaning extraction ───────────────────────── userSupportedMeaning: answerMeaning.userSupportedMeaning ?? null, possibleInference: answerMeaning.possibleInference ?? null, rawAnswerCategory: _deriveCategory(params?.answer), proposedMeaningCategory: _deriveCategory(answerMeaning.userSupportedMeaning || ""), // ── validation / guard checkpoints ─────────────────── proposalValidation: result?.proposalValidation ?? { success: false, errors: [] }, compatibilityGuard: result?.compatibilityApplied !== undefined ? { passed: result.compatibilityApplied, warnings: [], violations: [] } : { passed: false, warnings: [], violations: [] }, // ── graph mutation checkpoint (from appliedGraph) ──── graphMutation: result?.appliedGraph ? { nodes: result.appliedGraph.nodes ?? null, edges: result.appliedGraph.edges ?? null } : null, // ── graph structural checkpoint (available from updatedSituationGraph) ─ graphCheckpoint, // ── findings checkpoint (from input graph fixture, not production path) ─ findingsCount: { total: (params?.graph && Array.isArray(params.graph)) ? params.graph.length : (params?.graph && params.graph.nodes != null) ? params.graph.nodes.length : 0, eligible: "UNAVAILABLE", // not canonically observable without production contract change }, // ── CU narrative (NOT captured — synthesis is a separate seam from updateCase()) ─ currentUnderstanding: "UNAVAILABLE", // ── candidate evidence (UNAVAILABLE without changing production code) ──────── eligibleFindingPropositions: "UNAVAILABLE", // ── helper internals (for diagnostic / test verification) ──────────────────── selectedQuestion: result?.proposal?.selectedQuestion ?? null, behaviourSelection: result?.behaviourSelection ?? null, reasoningState: _extractReasoningState(result), }; } /** * Minimal deterministic category derivation from free-text (mirrors the production pipeline). */ function _deriveCategory(text) { if (!text || !text.trim()) return "none"; const lower = text.toLowerCase(); // Conditional patterns if (/normally\s+(?:avoid|skip|not\s+take|would\s+n't|can\'t)/i.test(lower) && /(might|could|would\s+(?:accept|allow|take|do))/i.test(lower)) { return "conditional_tradeoff"; } // Contrast / but patterns if (/but\s+i?\s*(don\'t|cannot|can\'t|won\'t|will\s+not)/i.test(lower)) { return "qualified_support"; } // Explicit hard constraint if (/(hard\s+constraint|must\s+(?:not|never|always)|can\'?\s*t(?:o)\s*(?:not|be\s+able\s+to)|absolutely\s+cannot)/i.test(lower)) { return "hard_constraint"; } // Strong preference / must positive if (/(must\s+(?:have|do|get)|absolutely\s+(?:need|require)|cannot\s+proceed\s+without)/i.test(lower)) { return "strong_preference"; } // Relative importance if (/more\s+to\s+me|matters\s+more|higher\s+priority|top\s*priority/i.test(lower)) { return "relative_importance"; } // Support / contraindicate if (/support(s)?\b|confirm(s)?\b|validates?\b/i.test(lower)) { return "supports_decision"; } if (/contradicts?\b|against\s+it\b|i\s*don\'?\s*t\s*(?:think\s+so|agree)\b/i.test(lower)) { return "contradicts_decision"; } return "cannot_determine"; } /** * Extract reasoning state from a raw updateCase() result object. * Exported for deterministic test verification of preserved behaviour. */ function _extractReasoningState(result) { const rs = result?.reasoningState; if (!rs) return null; return { turnCount: rs.turnCount ?? null, health: rs.health ?? null, phase: rs.phase ?? null, progress: rs.progress ?? null, unknownStatuses: (rs.unknownStatuses || []).map(s => s.id + ":" + s.status), }; } /** * Run one live experiment through the real updateCase() production path. */ async function runLiveExperiment({ graph, previousQuestion, answer, findings = [] }) { const baseUrl = assertRequiredEnv("OLLAMA_BASE_URL"); const model = assertRequiredEnv("OLLAMA_MODEL"); if (baseUrl === "http://localhost:11434" || baseUrl === "http://127.0.0.1:11434") { throw new Error( `Live experiment harness refuses to use localhost fallback. ` + `OLLAMA_BASE_URL=${baseUrl}. Configure a real host in .env.local.` ); } const { updateCase } = await import( "../../lib/graph/orchestrator.js" ); const { getProvider } = await import("../../lib/llm/provider.js"); const provider = getProvider(); let modelElapsedMs = null; let providerGenerateCalls = 0; const timedProvider = { async generateReconstruction(prompt, modelName, outputSchema) { providerGenerateCalls++; const startedAt = Date.now(); const response = await provider.generateReconstruction(prompt, modelName, outputSchema); modelElapsedMs = Date.now() - startedAt; return response; }, }; const endToEndStartedAt = Date.now(); const result = await updateCase( { situationGraph: graph, previousQuestion, answer }, { provider: timedProvider }, ); const endToEndElapsedMs = Date.now() - endToEndStartedAt; // ── accumulated state for longitudinal multi-episode use ─── const updatedGraph = result?.updatedSituationGraph ?? null; const postEpisodeFs = Array.isArray(result?.findings) ? [...result.findings] : []; return { /* ── CU narrative (UNAVAILABLE — synthesis is a separate seam from updateCase()) ─── */ currentUnderstanding: "UNAVAILABLE", /* ── updatedSituationGraph (NEW for longitudinal state feeding) ─── */ updatedSituationGraph: updatedGraph, /* ── postEpisodeFindings (NEW — accumulated Findings exposed for Episode N+1) ─── */ postEpisodeFindings: postEpisodeFs, /* ── graph checkpoint (from production result) ─── */ graphCheckpoint: updatedGraph ? { nodeCount: updatedGraph.nodes?.length ?? 0, edgeCount: updatedGraph.edges?.length ?? 0, resolvedNodeIds: updatedGraph.resolvedNodeIds ?? [], } : { nodeCount: 0, edgeCount: 0, resolvedNodeIds: [] }, /* ── findings checkpoint (count from accumulated Findings) ─── */ findingsCheckpoint: { total: postEpisodeFs.length, eligible: "UNAVAILABLE", }, /* ── Timing ─── */ modelElapsedMs: result?.modelElapsedMs !== undefined ? result.modelElapsedMs : null, endToEndElapsedMs: result?.endToEndElapsedMs !== undefined ? result.endToEndElapsedMs : null, providerGenerateCalls: result != null && result.providerGenerateCalls != null ? result.providerGenerateCalls : 0, modelName: result?.modelName || null, /* ── answer meaning (from production result) ─── */ userSupportedMeaning: result?.proposal?.answerMeaning?.userSupportedMeaning ?? null, possibleInference: result?.proposal?.answerMeaning?.possibleInference ?? null, /* ── helper internals (for diagnostic / test verification) ─── */ selectedQuestion: result?.proposal?.selectedQuestion ?? null, behaviourSelection: result?.behaviourSelection ?? null, reasoningState: _extractReasoningState(result), }; } /** * Compose the canonical post-episode deep-investigation checkpoint sequence: * updateCase → synthesizeCurrentUnderstanding * * Mirrors the production flow in ScenarioForm.handleDoneForNowPromotion / executeEpisodeDone: * 1. Call the episode done server seam (POST /api/cases/update, episodeMode=true) * which updates the SituationGraph and accumulates Findings * 2. Use the updated graph + accumulated findings to synthesize Current Understanding * via the same seam as production (POST /api/cases/synthesis → synthesizeCurrentUnderstanding) * * Returns a combined checkpoint containing: * - extractLiveExperimentCheckpoint(...) fields from the episode done result * - currentUnderstanding from the synthesis result * - findingsCheckpoint with accurate post-episode accumulated findings count */ /** * Compose the canonical post-episode deep-investigation checkpoint sequence. * Uses production-focused deconstruct for Contribution derivation (not synthetic raw-answer path). */ async function composeDeepInvestigationCheckpoint({ situationGraph, previousQuestion, answer, findings = [], contributions = [], targetNodeId, episodeMode }) { dotenv.config({ path: ".env.local" }); const baseUrl = assertRequiredEnv("OLLAMA_BASE_URL"); const model = assertRequiredEnv("OLLAMA_MODEL"); if (baseUrl === "http://localhost:11434" || baseUrl === "http://127.0.0.1:11434") { throw new Error( `Live experiment harness refuses to use localhost fallback. ` + `OLLAMA_BASE_URL=${baseUrl}. Configure a real host in .env.local.` ); } /* ── Step 1: episode done (mirrors executeEpisodeDone server seam) ─── */ const { updateCase, reconsiderCompletedEpisode } = await import( "../../lib/graph/orchestrator.js" ); const { getProvider } = await import("../../lib/llm/provider.js"); const provider = getProvider(); let modelElapsedMs = null; let providerGenerateCalls = 0; const timedProvider = { async generateReconstruction(prompt, modelName, outputSchema) { providerGenerateCalls++; const startedAt = Date.now(); const response = await provider.generateReconstruction(prompt, modelName, outputSchema); modelElapsedMs = Date.now() - startedAt; return response; }, }; const endToEndStartedAt = Date.now(); let episodeResult; /* ── Production-equivalent focused-episode seam: targetNodeId triggers the same path as executeEpisodeDone → handleEpisodeMode ─── */ const effectiveContributions = contributions.length > 0 ? [...contributions] : []; let targetDescription = ""; let effectiveContributionsForTarget = effectiveContributions; if (targetNodeId && effectiveContributions.length > 0) { effectiveContributionsForTarget = effectiveContributions.filter( (c) => c?.targetNodeId === targetNodeId, ); } let newFindingsFromContributions = []; if (effectiveContributionsForTarget.length === 0 && answer && targetNodeId) { const contribSeq = effectiveContributions.length + 1; /* ── Load production deconstruction seam (dynamic import to resolve ESM path aliases) ─── */ const { focusedDeconstruct } = await import("../../lib/graph/focused-investigation.js"); // Extract node context for deconstruction prompt const targetNode = situationGraph.nodes.find(n => n.id === targetNodeId); const targetLabel = targetNode?.label ?? ""; targetDescription = targetNode?.description ?? ""; const centralStatement = situationGraph.centralStatement ?? ""; // Call production-focused deconstruct — exactly one provider generation const focused = await focusedDeconstruct({ provider: timedProvider, modelName: model, targetNodeId, targetLabel, targetDescription, centralStatement, question: previousQuestion ?? "", answer, }); if (!focused.success) { throw new Error(`focusedDeconstruct failed for episode ${contribSeq}: ${JSON.stringify(focused.errors)}`); } // Map deconstruction result → canonical Contribution shape const derivedContrib = { id: `contrib-ep-${String(contribSeq).padStart(4, "0")}`, targetNodeId: focused.targetNodeId ?? targetNodeId, question: previousQuestion ?? "", answer: answer, sequence: contribSeq, observations: focused.observations, uncertainties: focused.uncertainties, assumptions: focused.assumptions, relationships: focused.relationships, possibleFollowUpQuestions: focused.possibleFollowUpQuestions, }; effectiveContributions.push(derivedContrib); // Derive canonical Findings from the deconstruction-derived Contribution (production seam) newFindingsFromContributions = deriveFindingsFromContributions([derivedContrib]).findings; } if (episodeMode === true && targetNodeId) { // Mirrors produceEpisodeAwareGraph: prepareCompletedEpisode → reconsiderCompletedEpisode → applyValidatedProposal const { prepareCompletedEpisode } = await import("../../lib/graph/episode-preparation.js"); const { applyValidatedProposal } = await import("../../lib/graph/apply-proposal.js"); const prepared = prepareCompletedEpisode({ situationGraph, targetNodeId, contributions: effectiveContributions, findings, }); if (!prepared?.turns?.length && !prepared?.eligibleCanonicalFindings?.length) { throw new Error("no_episodic_content: prepared episode has no turns or eligible findings"); } const reasoning = await reconsiderCompletedEpisode(prepared, { provider: timedProvider }); if (!reasoning.success) { throw new Error(`reconsiderCompletedEpisode failed: ${reasoning.error}`); } const application = await applyValidatedProposal({ situationGraph, proposal: reasoning.proposal, evidenceContext: { isCompletedEpisode: true, episodeEvidence: prepared, }, }); if (!application.success) { throw new Error(`applyValidatedProposal failed: ${application.error}`); } const resolvedNodeIds = new Set(application.updatedSituationGraph.resolvedNodeIds ?? []); resolvedNodeIds.add(targetNodeId); episodeResult = { success: true, updatedSituationGraph: { ...application.updatedSituationGraph, resolvedNodeIds: [...resolvedNodeIds], }, appendedFindings: [], // no regular update findings in episode path proposal: reasoning.proposal, }; } else { const result = await updateCase( { situationGraph, previousQuestion, answer }, { provider: timedProvider }, ); episodeResult = result; } const endToEndElapsedMs = Date.now() - endToEndStartedAt; /* ── Step 2: Current Understanding synthesis (mirrors production synthesizeFn) ─── */ const updatedGraph = episodeResult.updatedSituationGraph; // Accumulate prior findings + newly derived Findings (from new Contribution) for CU synthesis const priorFindings = Array.isArray(findings) ? [...findings] : []; const accumulatedEpisodes = episodeResult.appendedFindings ?? []; const postEpisodeFindings = [...priorFindings, ...newFindingsFromContributions, ...accumulatedEpisodes]; let currentUnderstanding = null; let synthesisErrorDiagnostic = null; if (episodeResult.success && updatedGraph) { // Reuse the same provider/model plumbing — no re-creation, just reuse const { synthesizeCurrentUnderstanding } = await import("../../lib/graph/current-understanding-synthesis.js"); try { const synthesized = await synthesizeCurrentUnderstanding( { situationGraph: updatedGraph, findings: postEpisodeFindings }, { provider: timedProvider, modelName: model }, ); currentUnderstanding = synthesized?.currentUnderstanding ?? null; } catch (synthErr) { // Expose the safe structural diagnostic from synthesis validation failure if (synthErr?.sanitizedDiagnostic) { synthesisErrorDiagnostic = synthErr.sanitizedDiagnostic; } currentUnderstanding = null; } } // Consistent graphCheckpoint for both success and failure paths const graphCheckpoint = updatedGraph ? { nodeCount: updatedGraph.nodes?.length ?? 0, edgeCount: updatedGraph.edges?.length ?? 0, resolvedNodeIds: updatedGraph.resolvedNodeIds ?? [], } : { nodeCount: 0, edgeCount: 0, resolvedNodeIds: [] }; // Accurate post-episode accumulated findings count const { filterEligibleFindings } = await import("../../lib/graph/current-understanding-synthesis.js"); const eligibleCount = postEpisodeFindings.length > 0 ? filterEligibleFindings(postEpisodeFindings).length : 0; return { /* ── CU synthesis result (mirrors production synthesizeFn) ─── */ currentUnderstanding, /* ── updatedSituationGraph (NEW — for longitudinal Episode N+1 state feeding) ─── */ updatedSituationGraph: updatedGraph, /* ── postEpisodeContributions (NEW — accumulated Contributions for longitudinal tracking) ─── */ postEpisodeContributions: effectiveContributions, /* ── postEpisodeFindings (NEW — accumulated Findings for Episode N+1) ─── */ postEpisodeFindings: postEpisodeFindings, /* ── Accurate post-episode accumulated findings count ─── */ findingsCheckpoint: { total: postEpisodeFindings.length, eligible: eligibleCount, }, /* ── graph checkpoint (from production result) ─── */ graphCheckpoint, /* ── Timing ─── */ modelElapsedMs, endToEndElapsedMs, providerGenerateCalls, modelName: episodeResult.modelName ?? null, /* ── CU synthesis error diagnostic (NEW — safe structural shape on validation failure) ─── */ synthesisErrorDiagnostic, /* ── Answer meaning (from production result) ─── */ userSupportedMeaning: episodeResult?.proposal?.answerMeaning?.userSupportedMeaning ?? null, possibleInference: episodeResult?.proposal?.answerMeaning?.possibleInference ?? null, /* ── Focused-investigation input values (apparatus correction) ─── */ focusedInvestigationInput: (previousQuestion != null || answer != null) ? { targetDescription: targetDescription ?? "", question: previousQuestion ?? "", answer, } : null, }; } /** * Serialize a completed deep-investigation checkpoint into the durable * focused-deconstruction evidence record. * * Pure function — no side effects, no I/O, no model calls. Intended for * deterministic test fixtures and external audit tools that need a stable * JSON-serializable representation of the five deconstruction arrays plus * exact input values and execution metadata. * * @param {object} checkpoint — returned from composeDeepInvestigationCheckpoint() * @param {object} identity — { experimentId, scenarioId, targetNodeId, evidenceDesignation } * - experimentId: unique identifier for the experiment run * - scenarioId: identifier for the scenario (e.g. "HS-03C") * - targetNodeId: the situationGraph node that was selected as focus * - evidenceDesignation: "synthetic" or "real-user" (or any string) * @param {object} [metadata] — optional execution metadata from the apparatus * - modelName, providerGenerateCalls, modelElapsedMs, endToEndElapsedMs, success * Any missing field is set to null (never fabricated). * @returns {object} JSON-serializable evidence record per fixed contract */ function serializeFocusedInvestigationEvidence(checkpoint, identity, metadata) { /* ── Extract the five structured deconstruction arrays ────────────── */ const contributions = Array.isArray(checkpoint?.postEpisodeContributions) ? checkpoint.postEpisodeContributions : []; // There should be exactly one contribution per focused-investigation run; // if multiple exist (episode path with accumulated contributions), // take the first one that carries the deconstruction arrays. const deconContribution = contributions.find( (c) => Array.isArray(c?.observations) && Array.isArray(c?.uncertainties) ) || contributions[0] || {}; return { /* ── Identity (supplied by caller, not derived) ─────────────────── */ experimentId: identity.experimentId ?? null, scenarioId: identity.scenarioId ?? null, targetNodeId: identity.targetNodeId ?? null, evidenceDesignation: identity.evidenceDesignation ?? null, /* ── Exact focused-investigation inputs (from checkpoint) ──────── */ canonicalTargetDescription: checkpoint.focusedInvestigationInput?.targetDescription ?? "", actualQuestion: checkpoint.focusedInvestigationInput?.question ?? "", exactAnswer: checkpoint.focusedInvestigationInput?.answer ?? "", /* ── Structured model output (preserved verbatim, no transformation) ─── */ observations: deconContribution.observations ?? null, uncertainties: deconContribution.uncertainties ?? null, assumptions: deconContribution.assumptions ?? null, relationships: deconContribution.relationships ?? null, possibleFollowUpQuestions: deconContribution.possibleFollowUpQuestions ?? null, /* ── Execution metadata (supplied by caller; null when not exposed) ─ */ modelName: metadata?.modelName ?? null, providerGenerateCalls: metadata?.providerGenerateCalls ?? null, modelElapsedMs: metadata?.modelElapsedMs ?? null, endToEndElapsedMs: metadata?.endToEndElapsedMs ?? null, executionStatus: (metadata?.success !== undefined) ? (metadata.success === true ? "completed" : "failed") : null, }; } /** * Save a completed focused-investigation checkpoint to a durable JSON file. * * Wrapper around `serializeFocusedInvestigationEvidence()` that performs * explicit persistence via the caller-supplied output path. Intended for * deterministic test fixtures and future live experiment runbooks — NOT * called automatically from production code. * * @param {object} checkpoint — returned from composeDeepInvestigationCheckpoint() * @param {object} identity — { experimentId, scenarioId, targetNodeId, evidenceDesignation } * @param {object} metadata — { modelName?, providerGenerateCalls?, modelElapsedMs?, endToEndElapsedMs?, success? } * @param {string} outputPath — explicit absolute or relative file path for the JSON output * @returns {{ output_path: string, verifiedEvidence: object }} { output_path, verifiedEvidence } * @throws {Error} on invalid inputs, missing required fields, ambiguous contribution, or I/O failure */ function saveFocusedInvestigationEvidence(checkpoint, identity, metadata, outputPath) { const fs = require("fs"); /* ── 2. Require explicit non-empty output path ────────────────────── */ if (!outputPath || typeof outputPath !== "string" || !outputPath.trim()) { throw new Error("saveFocusedInvestigationEvidence requires an explicit non-empty outputPath string"); } /* ── 3. Validate checkpoint has exactly one valid contribution ────── */ const contributions = Array.isArray(checkpoint?.postEpisodeContributions) ? checkpoint.postEpisodeContributions : []; const validContributionCandidates = contributions.filter( (c) => Array.isArray(c?.observations) && Array.isArray(c?.uncertainties) ); if (validContributionCandidates.length === 0) { throw new Error( "saveFocusedInvestigationEvidence requires a checkpoint with at least one valid focused-deconstruction contribution; found 0" ); } if (validContributionCandidates.length > 1) { throw new Error( "saveFocusedInvestigationEvidence rejects ambiguous checkpoint: multiple candidates exist (" + validContributionCandidates.length + "); supply a single-target checkpoint or resolve ambiguity before saving" ); } /* ── 4. Validate identity and input values ────────────────────────── */ if (!identity || typeof identity !== "object") { throw new Error("saveFocusedInvestigationEvidence requires an identity object"); } if (identity.experimentId == null) { throw new Error("saveFocusedInvestigationEvidence: identity.missing experimentId"); } if (identity.scenarioId == null) { throw new Error("saveFocusedInvestigationEvidence: identity.missing scenarioId"); } if (identity.targetNodeId == null) { throw new Error("saveFocusedInvestigationEvidence: identity.missing targetNodeId"); } if (identity.evidenceDesignation == null) { throw new Error("saveFocusedInvestigationEvidence: identity.missing evidenceDesignation"); } const fiInputs = checkpoint.focusedInvestigationInput; if (!fiInputs || typeof fiInputs !== "object") { throw new Error("saveFocusedInvestigationEvidence: checkpoint missing focusedInvestigationInput object"); } if (fiInputs.targetDescription == null || fiInputs.question == null || fiInputs.answer == null) { throw new Error( "saveFocusedInvestigationEvidence: checkpoint missing required focused-investigation input values (targetDescription, question, answer)" ); } /* ── 1. Serialize via existing serializer ─────────────────────────── */ const serialized = serializeFocusedInvestigationEvidence(checkpoint, identity, metadata); /* ── 5-6. Write the complete record as readable JSON ──────────────── */ const jsonContent = JSON.stringify(serialized, null, 2); fs.writeFileSync(outputPath, jsonContent, "utf8"); /* ── 7-8. Read back and verify structural equality ────────────────── */ const recoveredRaw = fs.readFileSync(outputPath, "utf8"); const recovered = JSON.parse(recoveredRaw); if (JSON.stringify(serialized) !== JSON.stringify(recovered)) { throw new Error( "saveFocusedInvestigationEvidence: write/read-back verification failed — recovered JSON does not match serialized record" ); } /* ── 9. Return output path and verified evidence record ───────────── */ return { output_path: outputPath, verifiedEvidence: recovered }; } module.exports = { runLiveExperiment, extractLiveExperimentCheckpoint, _extractReasoningState, composeDeepInvestigationCheckpoint, serializeFocusedInvestigationEvidence, saveFocusedInvestigationEvidence };