diff --git a/tests/graph/live-update-experiment-helper-checkpoint.test.cjs b/tests/graph/live-update-experiment-helper-checkpoint.test.cjs index 357d22a..1fabc7b 100644 --- a/tests/graph/live-update-experiment-helper-checkpoint.test.cjs +++ b/tests/graph/live-update-experiment-helper-checkpoint.test.cjs @@ -324,23 +324,25 @@ assert(helperSrc.includes("postEpisodeFindings: postEpisodeFindings"), "postEpis const extractResult = extractLiveExperimentCheckpoint(successResult(123, 456), successParams); assert(extractResult.providerGenerateCalls === 1, "providerGenerateCalls preserved from extraction path"); -// ---- CONTRIBUTION SEAM VERIFICATION (Iteration 4) ────────────────────── +// ---- CONTRIBUTION SEAM VERIFICATION (Iteration 4 + Iteration 5 deconstruction) ────────────────────── console.log("\n=== CONTRIBUTION SEAM VERIFICATION ===\n"); // C1. composeDeepInvestigationCheckpoint accepts contributions parameter (defaults to []) assert(composeDeepInvestigationCheckpoint.length >= 1, "composeDeepInvestigationCheckpoint accepts at least 1 param (contributions default)"); -// C2. Production parity: helper derives Contribution from answer when not supplied -assert(helperSrc.includes("_deriveContributionFromAnswer"), "helper has _deriveContributionFromAnswer (mirrors production appendFocusedContribution)"); +// C2. Canonical deconstruction replaces synthetic raw-answer path for production-faithful Contribution derivation +assert(helperSrc.includes("focusedDeconstruct"), "helper uses canonical focusedDeconstruct for Contribution derivation"); +assert(!helperSrc.includes("_deriveContributionFromAnswer"), "synthetic _deriveContributionFromAnswer removed from apparatus"); -// C3. Contribution carries targetNodeId matching focused target -assert(helperSrc.includes("targetNodeId: targetNodeId") || helperSrc.includes("targetNodeId : targetNodeId"), "Contribution derives targetNodeId from focused target"); +// C3. Contribution targetNodeId comes from focusedDeconstruct result / production focus input +assert(helperSrc.includes("targetNodeId: targetNodeId") || helperSrc.includes("targetNodeId : targetNodeId") || helperSrc.includes("targetNodeId: focused.targetNodeId"), "Contribution derives targetNodeId from deconstruction (production target)"); -// C4. Contribution fields match production shape -assert(helperSrc.includes("observations:") && helperSrc.includes("answer:"), "Contribution includes observations and answer fields (production shape)"); +// C4. All five deconstruction semantic fields preserved in Contribution shape +assert(helperSrc.includes("observations:") && helperSrc.includes("uncertainties:") && helperSrc.includes("assumptions:") && helperSrc.includes("relationships:"), "Contribution carries all five deconstruction semantic fields"); +assert(helperSrc.includes("possibleFollowUpQuestions:"), "Contribution includes possibleFollowUpQuestions from deconstruction"); -// C5. deriveFindingsFromContributions is used to derive Findings from new Contribution (same production helper) -assert(helperSrc.includes("deriveFindingsFromContributions"), "helper reuses produce deriveFindingsFromContributions for Finding derivation"); +// C5. deriveFindingsFromContributions is used to derive Findings from Contribution (production seam unchanged) +assert(helperSrc.includes("deriveFindingsFromContributions"), "helper reuses production deriveFindingsFromContributions for Finding derivation"); // C6. Effective contributions passed to prepareCompletedEpisode (not empty array) assert(helperSrc.includes("contributions: effectiveContributions") || helperSrc.includes("contributions : effectiveContributions"), "helper passes effectiveContributions to prepareCompletedEpisode (not empty)"); @@ -357,5 +359,56 @@ assert(helperSrc.includes("postEpisodeContributions: effectiveContributions") || // C10. Source code verifies contributions parameter default behavior (empty array) assert(helperSrc.includes("contributions = []"), "contributions defaults to empty array when not supplied"); +// ---- DECONSTRUCTION INTEGRATION TESTS (Iteration 5) ────────────────────── +console.log("\n=== DECONSTRUCTION INTEGRATION TESTS ===\n"); + +// D1. Compose function is actually callable (not just exported stub) +assert(typeof composeDeepInvestigationCheckpoint === "function", "composeDeepInvestigationCheckpoint is a real function (callable)"); + +// D2. Uses canonical focusedDeconstruct import (production seam, not synthetic derivation) +assert(helperSrc.includes("focused-deconstruct") || helperSrc.includes("focusedDeconstruct"), "focusedDeconstruct available via production seam in apparatus"); + +// D3. Reuses existing provider/model plumbing — no second getProvider call within composeDeepInvestigationCheckpoint +// The helper's single getProvider() in composeDeepInvestigationCheckpoint feeds both the timedWrapper and focusedDeconstruct +const composeHelperSection = helperSrc.substring( + helperSrc.indexOf("async function composeDeepInvestigationCheckpoint"), + helperSrc.indexOf("module.exports") +); +const cpGetProviderCalls = (composeHelperSection.match(/getProvider\(\)/g) || []).length; +assert(cpGetProviderCalls === 1, `composeDeepInvestigationCheckpoint getsProvider() exactly once (${cpGetProviderCalls}) — reused for timedProvider and deconstruction`); + +// D4. All five semantic fields preserved from focusedDeconstruct result → Contribution (not synthetic defaults) +assert(helperSrc.includes("observations: focused.observations"), "observations from focusedDeconstruct, not [answer]"); +assert(helperSrc.includes("uncertainties: focused.uncertainties"), "uncertainties from focusedDeconstruct, not []"); +assert(helperSrc.includes("assumptions: focused.assumptions"), "assumptions from focusedDeconstruct, not synthetic"); +assert(helperSrc.includes("relationships: focused.relationships"), "relationships from focusedDeconstruct, not synthetic"); +assert(helperSrc.includes("possibleFollowUpQuestions: focused.possibleFollowUpQuestions"), "follow-ups from focusedDeconstruct, not []"); + +// D5. Findings derive from deconstructed observations via production seam — NOT raw answer text +assert(!helperSrc.includes("observations: [answer]"), "Findings NOT derived from raw answer as observations"); +assert(helperSrc.includes("deriveFindingsFromContributions([derivedContrib])"), "Findings derived from deconstruction-derived Contribution"); + +// D6. Prior Contributions accumulate exactly once — effectiveContributions built once, pushed once +assert(helperSrc.includes("effectiveContributions.push(derivedContrib)"), "one derived Contribution accumulated (exactly-once push)"); + +// D7. Prior Findings accumulate exactly once — postEpisodeFindings = prior + newDeconstructed + appended +assert(composeHelperSection.includes("postEpisodeFindings") && composeHelperSection.includes("[...priorFindings, ...newFindingsFromContributions"), "findings accumulation: prior + derived from deconstruction (exactly-once)"); + +// D8. Episode reconsideration still executes via reconsiderCompletedEpisode +assert(helperSrc.includes("reconsiderCompletedEpisode"), "episode reconsideration still executes post-deconstruction"); + +// D9. CU synthesis still executes with accumulated findings +assert(helperSrc.includes("postEpisodeFindings") && helperSrc.includes("synthesizeCurrentUnderstanding"), "CU synthesis uses full accumulated findings"); + +// D10. Total provider accounting: focusedDeconstruct (1) + reconsideration (1) = 2 before CU synthesis (3 total in full composed episode) +const deconProviderCalls = (helperSrc.match(/focused\.success|focused\.errors/g) || []).length; +assert(deconProviderCalls >= 2, "deconstruction result checked (success/errors — confirms provider call used)"); + +// D11. Next graph returned via updatedSituationGraph +assert(helperSrc.includes("updatedSituationGraph: updatedGraph"), "next graph returned in checkpoint"); + +// D12. Next Contributions/Findings returned for Episode N+1 +assert(helperSrc.includes("postEpisodeContributions") && helperSrc.includes("postEpisodeFindings"), "next Contributions and Findings returned for longitudinal tracking"); + console.log("\n--- Total failures: " + failures + " ---\n"); process.exit(failures > 0 ? 1 : 0); \ No newline at end of file diff --git a/tests/graph/live-update-experiment-helper.cjs b/tests/graph/live-update-experiment-helper.cjs index 5aa23b4..67968bb 100644 --- a/tests/graph/live-update-experiment-helper.cjs +++ b/tests/graph/live-update-experiment-helper.cjs @@ -272,23 +272,9 @@ async function runLiveExperiment({ graph, previousQuestion, answer, findings = [ * - findingsCheckpoint with accurate post-episode accumulated findings count */ /** - * Deterministically derive a canonical Contribution from the fixed answer - * when the caller does not supply one. Mirrors production appendFocusedContribution - * shape: id, targetNodeId, question, answer, sequence, observations, etc. + * Compose the canonical post-episode deep-investigation checkpoint sequence. + * Uses production-focused deconstruct for Contribution derivation (not synthetic raw-answer path). */ -function _deriveContributionFromAnswer({ answer, previousQuestion, targetNodeId, sequence }) { - return { - id: `contrib-ep-${String(sequence).padStart(4, "0")}`, - targetNodeId: targetNodeId ?? null, - question: previousQuestion ?? "", - answer: answer ?? "", - sequence: sequence, - observations: [answer], - uncertainties: [], - possibleFollowUpQuestions: [], - }; -} - async function composeDeepInvestigationCheckpoint({ situationGraph, previousQuestion, answer, findings = [], contributions = [], targetNodeId, episodeMode }) { dotenv.config({ path: ".env.local" }); @@ -328,15 +314,54 @@ async function composeDeepInvestigationCheckpoint({ situationGraph, previousQues /* ── Production-equivalent focused-episode seam: targetNodeId triggers the same path as executeEpisodeDone → handleEpisodeMode ─── */ - // Derive a canonical Contribution from the fixed answer when none supplied. - // Mirrors production appendFocusedContribution: user answer → canonical Contribution + /* ── Canonical deconstruction: use focusedDeconstruct for Contribution derivation ─── */ const effectiveContributions = contributions.length > 0 ? [...contributions] : []; let newFindingsFromContributions = []; + if (effectiveContributions.length === 0 && answer && targetNodeId) { const contribSeq = effectiveContributions.length + 1; - const derivedContrib = _deriveContributionFromAnswer({ answer, previousQuestion, targetNodeId, sequence: contribSeq }); + + /* ── 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 ?? ""; + const targetDescription = targetNode?.description ?? ""; + const centralStatement = situationGraph.centralStatement ?? ""; + + // Call production-focused deconstruct — exactly one provider generation + const focused = focusedDeconstruct({ + provider, + 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 Contribution (production seam) + + // Derive canonical Findings from the deconstruction-derived Contribution (production seam) newFindingsFromContributions = deriveFindingsFromContributions([derivedContrib]).findings; }