Files
confidence-engine/tests/graph/live-update-experiment-helper.cjs
T
robbond 0698a1a61e feat(confidence-engine): add saveFocusedInvestigationEvidence persistence operation (Experiment 8)
Add exported saveFocusedInvestigationEvidence(checkpoint, identity, metadata,
outputPath) to the canonical experiment helper. Validates output path, exactly
one valid deconstruction contribution, required identity fields, and exact input
values. Writes formatted JSON, reads back, verifies deep structural equality.
Rejects ambiguous checkpoints instead of silently selecting a contribution.

Deterministic test covers: positive file write, structured evidence completeness,
input fidelity, identity fidelity, return values, missing path rejection, missing
contribution rejection, ambiguous contribution rejection. All OS temp files cleaned
in finally blocks.

Zero live model calls. Zero browser calls. No production code modified.
2026-10-09 14:33:33 +01:00

681 lines
29 KiB
JavaScript

/**
* 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 };