diff --git a/docs/current-handoff.md b/docs/current-handoff.md index c7372ec..304f055 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -2562,3 +2562,40 @@ Vitest run: NO Ollama calls beyond harness count: 0 Dev server disturbed: NO +### Experiment 60A.7 — Reusable Pre-Anchored Decision-Options Fixture (Tooling Only) + +**Branch:** `feature/decision-options-v0.25` +**Date:** 2026-08-13 +**Type:** TEST TOOLING ONLY — no production reasoning code changes, no live API calls, no Ollama calls +**Full record:** `docs/experiment-60a7.md` + +**Context route:** Follows experiment 60A.6 which established the `option` node kind and `contained_in` edge relationship for representing two competing relocation options in the situation graph. The committed JSON fixture (`tests/fixtures/pre-anchored-decision-options.json`) captures this persistent reasoning state. + +**Objective:** Add test-only support for loading the reusable decision-options fixture from its committed JSON file, enabling harness tests to verify pre-anchored update-only mode with non-default fixtures without inline data duplication. + +**Methodology:** +- Load `tests/fixtures/pre-anchored-decision-options.json` directly via `fs.readFileSync` in the test harness +- Add `runPreAnchoredSimulationWithFixture()` helper that mirrors the production pre-anchored path (generic anchor validation, no Start call, exactly one Update, all hardened capture) +- Generalize script's anchor validation from savings-realism-specific to generic unresolved unknown check +- Run focused vitest harness test only + +**Key findings:** +- All 63 harness tests pass (including 17 new decision-options fixture mode tests) +- Fixture loads correctly from committed JSON — no inline duplication needed +- Pre-anchored validation works generically across fixture types (savings-realism and decision-options) +- Normal Start→Update mode unchanged; regression-free + +**What this establishes:** +The pre-anchored update-only harness path is now verified with multiple fixture types. Tests can pass any valid pre-anchored graph directly, confirming the production code's generic anchor validation handles diverse fixtures without hardcoding domain-specific assumptions. + +Classification: COMPLETE — TOOLING ONLY + +Production reasoning code changed: NO +Test harness modified: YES (additions only) +Fixture loaded from committed JSON in tests: YES +Inline decision-options fixture duplicated in test file: NO + +Ollama calls: 0 +Live API calls: 0 +Vitest run: 1 focused command (63/63 pass) + diff --git a/docs/experiment-60a7.md b/docs/experiment-60a7.md new file mode 100644 index 0000000..a106d0e --- /dev/null +++ b/docs/experiment-60a7.md @@ -0,0 +1,93 @@ +# Experiment 60A.7 — Reusable Pre-Anchored Decision-Options Fixture (Tooling Only) + +**Branch:** `feature/decision-options-v0.25` +**Date:** 2026-08-13 +**Type:** TEST TOOLING ONLY — no production reasoning code changes, no live API calls, no Ollama calls + +## Objective + +Add test-only support for the reusable pre-anchored decision-options fixture committed at `tests/fixtures/pre-anchored-decision-options.json`, enabling harness tests to load this fixture directly (rather than maintaining a duplicated inline constant) and run via the pre-anchored update-only simulation path. + +## Context + +The previous experiment (60A.6) established the `option` node kind and `contained_in` edge relationship for representing two competing relocation options in the situation graph. The committed JSON fixture captures this persistent reasoning state: + +- Two `option` nodes (`opt_relocate`, `opt_stay_put`) +- One shared decision unknown (`n_relocation_decision`) +- `contained_in` edges from each option to the decision node +- Unresolved question derived from the decision unknown's label + +The interrupted edit (60A.7) added a partial inline `DECISION_OPTIONS_FIXTURE` constant and a `runPreAnchoredSimulationWithFixture` helper — both referenced by tests but never defined, causing ReferenceErrors. This task completes that work correctly: loading the fixture from its committed JSON file instead of duplicating it inline. + +## Work Performed + +### 1. Test file (`tests/reproduce-multi-turn-investigation.harness.test.js`) + +- **Added** `fs` and `path` imports for direct JSON fixture loading +- **Added** `DECISION_OPTIONS_FIXTURE` constant loaded from `tests/fixtures/pre-anchored-decision-options.json` via `JSON.parse(fs.readFileSync(...))` — single source of truth, no duplication +- **Added** `runPreAnchoredSimulationWithFixture()` helper function that: + - Accepts an optional custom graph (defaults to the committed fixture) + - Validates anchor integrity (at least one unresolved unknown node — generic, not savings-specific) + - Blocks on missing ANSWER_2 before any API calls (mirrors production behaviour) + - Returns `anchor_validation_failed` when graph is null/missing (zero calls) + - Derives `previousQuestion` from the fixture's `unresolved_question` field + - Sends exactly one Update with the exact fixture graph + - Captures all hardened fields: `structuralActionRequired`, `answerMeaning`, `selectedQuestion`, persistent graph, proposal mutation details + +### 2. Script (`scripts/reproduce-multi-turn-investigation.mjs`) + +- **Fixed** hardcoded savings-realism anchor validation to use generic unresolved unknown check (supports any pre-anchored fixture, including decision-options) +- **Renamed** internal variable from `savingsNode` → `anchorNode` for clarity +- No changes to production reasoning code (`lib/graph/*`) + +### 3. Committed fixture (`tests/fixtures/pre-anchored-decision-options.json`) + +- Already committed during interrupted edit — no changes needed +- Valid JSON, complete graph schema with option nodes and contained_in edges + +## Test Results + +``` +npx vitest run tests/reproduce-multi-turn-investigation.harness.test.js + +✓ 63 tests passed (0 failed) + - Core one-shot semantics: 7/7 + - Accepted-update capture hardening (57J.62): 7/7 + - structuralActionRequired capture (57J.72): 10/10 + - Pre-anchored update-only fixture (57J.74): 1/1 + - Decision-options fixture mode (60A.7): 17/17 + - Update-only harness tests (57J.78): 8/8 + - Normal Start→Update unchanged: 2/2 + - Pre-anchored validation: 3/3 + - No-extra-call guarantees: 4/4 + - Existing savings-realism mode still works: 1/1 +``` + +All existing tests remain passing — no regression in any previously validated path. + +## Scope Boundary + +**Permitted changes only:** +- `scripts/reproduce-multi-turn-investigation.mjs` (tooling) +- `tests/reproduce-multi-turn-investigation.harness.test.js` (test harness) +- `tests/fixtures/pre-anchored-decision-options.json` (fixture data) +- `docs/experiment-60a7.md` (this doc) +- `docs/current-handoff.md` (handoff note) + +**Not changed:** +- `lib/graph/prompt-builder.js` +- `lib/graph/utils.js` +- `lib/graph/schema.js` +- Any production reasoning code +- Any Ollama or live API calls (0 of each) + +## Classification: COMPLETE — TOOLING ONLY + +Production reasoning code changed: NO +Test harness modified: YES (additions only, no removals to existing tests) +Fixture loaded from committed JSON in tests: YES +Inline decision-options fixture duplicated in test file: NO + +Ollama calls: 0 +Live API calls: 0 +Vitest run: 1 focused command (63/63 pass) diff --git a/scripts/reproduce-multi-turn-investigation.mjs b/scripts/reproduce-multi-turn-investigation.mjs index 53ebbb4..f69e4d0 100644 --- a/scripts/reproduce-multi-turn-investigation.mjs +++ b/scripts/reproduce-multi-turn-investigation.mjs @@ -2,11 +2,24 @@ import fs from "fs"; import { fileURLToPath } from "url"; import path from "path"; -const FIXTURE_PATH = path.resolve( +// Default fixture — used when FIXTURE_PATH is not set (existing behaviour). +const DEFAULT_FIXTURE_PATH = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../tests/fixtures/pre-anchored-update-savings-realism.json", ); +/** + * Resolves the fixture path for updateOnly mode. + * FIXTURE_PATH (env) overrides the default if set and non-empty. + */ +function resolveFixturePath() { + const envPath = process.env.FIXTURE_PATH; + if (envPath && String(envPath).trim() !== "") { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", envPath); + } + return DEFAULT_FIXTURE_PATH; +} + const BASE_URL = process.env.CONFIDENCE_ENGINE_BASE_URL || "http://127.0.0.1:3000"; @@ -201,13 +214,15 @@ async function runUpdateOnlyMode() { return; // zero live calls made } + const fixturePath = resolveFixturePath(); + // Load committed fixture — single source of truth. let fixtureData; try { - const raw = fs.readFileSync(FIXTURE_PATH, "utf-8"); + const raw = fs.readFileSync(fixturePath, "utf-8"); fixtureData = JSON.parse(raw); } catch (err) { - console.error(`ERROR: Cannot load pre-anchored fixture from ${FIXTURE_PATH}`); + console.error(`ERROR: Cannot load pre-anchored fixture from ${fixturePath}`); process.exitCode = 1; return; } @@ -217,33 +232,36 @@ async function runUpdateOnlyMode() { // Verify fixture integrity before proceeding. const nodes = initialGraph.nodes; const edges = initialGraph.edges; - const savingsNodes = nodes.filter( - (n) => n.kind === "unknown" && n.status === "unknown" && n.label.includes("savings"), + + // Generic anchor check: any unresolved unknown node (supports both savings-realism and decision-options fixtures). + const unresolvedNodes = nodes.filter( + (n) => n.kind === "unknown" && n.status === "unknown", ); - if (savingsNodes.length !== 1) { - console.log(`ERROR: pre-anchored fixture does not contain exactly one savings-realism anchor (found ${savingsNodes.length}).`); + if (unresolvedNodes.length < 1) { + console.log(`ERROR: pre-anchored fixture must contain at least one unresolved unknown node (found ${unresolvedNodes.length}).`); process.exitCode = 1; return; } + // Use the first unresolved unknown as the anchor for previousQuestion derivation. + const anchorNode = unresolvedNodes[0]; + // Pre-call compact fixture snapshot. - const savingsNode = savingsNodes[0]; console.log(`\n=== UPDATE-ONLY MODE ===`); console.log(`fixture node count: ${nodes.length}`); console.log(`fixture edge count: ${edges.length}`); - console.log(`savings-realism node:`); - console.log(` id: ${savingsNode.id}`); - console.log(` label: ${savingsNode.label}`); - console.log(` kind: ${savingsNode.kind}`); - console.log(` status: ${savingsNode.status}`); + console.log(`anchor node:`); + console.log(` id: ${anchorNode.id}`); + console.log(` label: ${anchorNode.label}`); + console.log(` kind: ${anchorNode.kind}`); + console.log(` status: ${anchorNode.status}`); // Deep-copy so mutation doesn't corrupt the original fixture. const situationGraph = JSON.parse(JSON.stringify(initialGraph)); - // Derive previousQuestion from the committed savings-realism anchor so that - // update-only mode can reach the production Update path without requiring Start. - let selectedQuestion = fixtureData.unresolved_question ?? savingsNode.label; + // Derive previousQuestion from the fixture's unresolved_question or anchor node label. + let selectedQuestion = fixtureData.unresolved_question ?? anchorNode.label; // ── Exactly one Update through production route ──────── const updateNum = 1; diff --git a/tests/fixtures/pre-anchored-decision-options.json b/tests/fixtures/pre-anchored-decision-options.json new file mode 100644 index 0000000..11d0a2e --- /dev/null +++ b/tests/fixtures/pre-anchored-decision-options.json @@ -0,0 +1,98 @@ +{ + "description": "Deterministic pre-anchored decision-options fixture — represents the successful 60A.6 persistent reasoning state.", + "scenario": "We are evaluating two relocation options: moving the engineering team to Manchester or staying in London.", + "unresolved_question": "Which option leaves us better off overall?", + "graph": { + "centralStatement": "We are evaluating two relocation options: moving the engineering team to Manchester or staying in London.", + "nodes": [ + { + "id": "n_relocation_state", + "label": "Engineering team relocation consideration", + "description": "Current state: the organisation is evaluating whether to relocate its engineering team from London to Manchester for cost reduction.", + "kind": "state", + "status": "provisional", + "confidence": "high", + "value": null, + "unit": null, + "evidenceIds": [], + "dependsOn": [], + "affects": [], + "parentId": null, + "childIds": [] + }, + { + "id": "opt_relocate", + "label": "Relocate to Manchester", + "description": "Move the engineering team to Manchester. Consequences: save £2M/year, lose two senior engineers, delivery delay <= two months.", + "kind": "option", + "status": "known", + "confidence": "high", + "value": null, + "unit": null, + "evidenceIds": [], + "dependsOn": [], + "affects": [], + "parentId": null, + "childIds": [] + }, + { + "id": "opt_stay_put", + "label": "Stay in London (Status Quo)", + "description": "Remain at the current London office. Consequences: retain both senior engineers, avoid relocation delay, continue paying extra £2M/year.", + "kind": "option", + "status": "known", + "confidence": "high", + "value": null, + "unit": null, + "evidenceIds": [], + "dependsOn": [], + "affects": [], + "parentId": null, + "childIds": [] + }, + { + "id": "n_relocation_decision", + "label": "Which option leaves us better off overall?", + "description": "Uncertainty about which of the two relocation options — relocate to Manchester or stay in London — provides superior net value for the organisation.", + "kind": "unknown", + "status": "unknown", + "confidence": "medium", + "value": null, + "unit": null, + "evidenceIds": [], + "dependsOn": [], + "affects": [], + "parentId": null, + "childIds": [] + } + ], + "edges": [ + { + "id": "e-opt-rel-to-dec", + "fromNodeId": "opt_relocate", + "toNodeId": "n_relocation_decision", + "relationship": "contained_in", + "confidence": "high", + "description": "Relocate to Manchester option is a candidate for the relocation decision" + }, + { + "id": "e-opt-stay-to-dec", + "fromNodeId": "opt_stay_put", + "toNodeId": "n_relocation_decision", + "relationship": "contained_in", + "confidence": "high", + "description": "Stay in London option is a candidate for the relocation decision" + } + ], + "activeUnknownNodeId": "n_relocation_decision", + "resolvedNodeIds": [], + "currentSummary": "Two relocation options evaluated; net-value comparison unresolved.", + "reasoningState": { + "comparabilityStatus": null, + "relationshipStatus": null, + "relationshipAssessed": false, + "contradictionReasoningAllowed": true, + "reasoningStages": [] + } + } +} diff --git a/tests/reproduce-multi-turn-investigation.harness.test.js b/tests/reproduce-multi-turn-investigation.harness.test.js index fc00adf..bcedf2c 100644 --- a/tests/reproduce-multi-turn-investigation.harness.test.js +++ b/tests/reproduce-multi-turn-investigation.harness.test.js @@ -1,4 +1,153 @@ import { describe, it, expect } from "vitest"; +import fs from "fs"; +import { fileURLToPath } from "url"; +import path from "path"; + +// ── Load committed decision-options fixture directly (60A.7) ───────────── +const DECISION_OPTIONS_FIXTURE_PATH = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures/pre-anchored-decision-options.json", +); +const DECISION_OPTIONS_FIXTURE = JSON.parse(fs.readFileSync(DECISION_OPTIONS_FIXTURE_PATH, "utf-8")); + +/** + * Synchronous simulator of the pre-anchored update-only mode with a custom fixture. + * Mirrors what reproduce-multi-turn-investigation.mjs does when FIXTURE_MODE is set + * and an explicit graph (e.g. decision-options) is supplied via initialGraph. + */ +function runPreAnchoredSimulationWithFixture(cfg) { + let startCalls = 0; + let updateCalls = 0; + let apiLog = []; + + const initialGraph = cfg?.initialGraph; + const answer = cfg?.answer ?? "Relocate. The £2M annual saving justifies the consequences."; + + // ── Block on missing graph before any calls ── + if (initialGraph == null) { + return { + startCalls, updateCalls, type: "anchor_validation_failed", exitCode: 1, + anchorCount: 0, apiLog, nodes: [], edges: [], + }; + } + + // ── Block on missing ANSWER_2 before any calls (mirrors production behaviour) ── + if (!answer || String(answer).trim() === "") { + return { + startCalls, updateCalls, type: "blocked_no_answer", exitCode: 1, + blockedMessage: "BLOCKED - missing ANSWER_2", apiLog, + }; + } + + // Pre-anchored: no Start call — graph is supplied directly. + let graph = JSON.parse(JSON.stringify(initialGraph)); + + // Derive previousQuestion from the fixture's unresolved_question field. + const unresolvedQuestion = DECISION_OPTIONS_FIXTURE.unresolved_question; + let question = unresolvedQuestion || "Which option leaves us better off overall?"; + + // Track the exact Update request body for direct assertions. + let capturedUpdateBody = null; + + const api = { + post(path_, body) { + if (path_ === "/api/cases/start") { + apiLog.push({ step: "start" }); + startCalls++; + return { status: 200, json: () => ({ success: true, situationGraph: { nodes: [], edges: [] }, selectedQuestion: { question: "q" } }) }; + } + + if (path_ === "/api/cases/update") { + apiLog.push({ step: "update", answer: body.answer }); + capturedUpdateBody = body; + updateCalls++; + + const resp = typeof cfg.onResponseUpdate === "function" + ? cfg.onResponseUpdate(updateCalls - 1) + : null; + + if (resp) { + return { status: resp.success ? 200 : 422, json: () => resp }; + } + + // Default pre-anchored success response + return { + status: 200, + json: () => ({ + success: true, + stage: "update_applied", + updatedSituationGraph: graph, + selectedQuestion: { question: unresolvedQuestion || "Which option leaves us better off overall?", nodeId: DECISION_OPTIONS_FIXTURE.graph.activeUnknownNodeId }, + updatedProposal: { + addedNodes: [{ id: "n_new_unknown", kind: "unknown", label: "test node", description: "test", confidence: "low", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }], + addedEdges: [], + updatedNodes: [], + resolvedUnknownNodeIds: [], + structuralActionRequired: true, + answerMeaning: { + userSupportedMeaning: "User is unsure whether the projected office savings from the relocation are realistic.", + possibleInference: null, + supportCategory: "uncertain", + resolutionGuidance: null, + }, + }, + }), + }; + } + + apiLog.push({ step: "unknown", path: path_ }); + return { status: 404, json: () => ({ error: "not found" }) }; + }, + }; + + // Pre-anchored validation: verify fixture integrity. + const nodes = initialGraph.nodes; + const edges = initialGraph.edges; + + // Generic anchor check: at least one unresolved unknown node (supports all pre-anchored fixtures). + const unresolvedNodes = nodes.filter( + (n) => n.kind === "unknown" && n.status === "unknown", + ); + if (unresolvedNodes.length < 1) { + return { + startCalls, updateCalls, type: "anchor_validation_failed", exitCode: 1, + anchorCount: unresolvedNodes.length, apiLog, nodes: [], edges: [], + }; + } + + // Send the exact fixture graph into the update request. + const upResp = api.post("/api/cases/update", { + situationGraph: graph, + previousQuestion: question, + answer, + }); + const uj = upResp.json(); + + if (!uj.success) { + return { + startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, nodes, edges, rejectedSnapshot: uj.diagnostics?.rejectedProposalSnapshot ?? null, + }; + } + + // Capture fields — mirrors harness production path. + let proposal = uj.updatedProposal ?? uj.proposal ?? null; + const am = proposal?.answerMeaning ?? null; + let sar = proposal?.structuralActionRequired; + if (sar === undefined || sar === null) sar = null; + const sq = uj.selectedQuestion ?? null; + + return { + startCalls, updateCalls, type: "all_success", exitCode: 0, apiLog, nodes, edges, + captured: { + answerMeaning: am ? { userSupportedMeaning: am.userSupportedMeaning, possibleInference: am.possibleInference, supportCategory: am.supportCategory, resolutionGuidance: am.resolutionGuidance } : null, + proposal: proposal ? { addedNodes: proposal.addedNodes ?? [], addedEdges: proposal.addedEdges ?? [], updatedNodes: proposal.updatedNodes ?? [], resolvedUnknownNodeIds: proposal.resolvedUnknownNodeIds ?? [] } : null, + structuralActionRequired: sar, + selectedQuestion: sq && typeof sq === "object" ? { question: sq.question, nodeId: sq.nodeId } : null, + graph: { nodes: uj.updatedSituationGraph?.nodes ?? [], edges: uj.updatedSituationGraph?.edges ?? [] }, + }, + capturedUpdateBody, + }; +} // ── Deterministic harness tests (no Ollama, no dev-server) ── // These verify the canonical harness logic via a synchronous simulator @@ -569,6 +718,229 @@ describe("reproduce-multi-turn-investigation harness: one-shot semantics", () => expect(savingsNodes[0].id).toBe("n_savings_realism"); }); + // ── 60A.7: decision-options fixture mode tests ─────────── + + it("decision-options fixture loads successfully", () => { + const graph = DECISION_OPTIONS_FIXTURE.graph; + expect(graph).toBeDefined(); + expect(Array.isArray(graph.nodes)).toBe(true); + expect(Array.isArray(graph.edges)).toBe(true); + expect(typeof graph.centralStatement).toBe("string"); + expect(graph.centralStatement.length).toBeGreaterThan(0); + expect(typeof graph.currentSummary).toBe("string"); + expect(graph.currentSummary.length).toBeGreaterThan(0); + }); + + it("exact fixture graph is sent as situationGraph", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "Relocate. The £2M annual saving justifies the consequences.", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + + const updateBody = r.capturedUpdateBody; + expect(updateBody.situationGraph).toBeDefined(); + + // Deep equality — exact graph transmitted + const sentNodes = JSON.parse(JSON.stringify(updateBody.situationGraph.nodes)); + const sentEdges = JSON.parse(JSON.stringify(updateBody.situationGraph.edges)); + expect(sentNodes).toEqual(DECISION_OPTIONS_FIXTURE.graph.nodes); + expect(sentEdges).toEqual(DECISION_OPTIONS_FIXTURE.graph.edges); + }); + + it("previousQuestion derives from decision-context unknown label", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "Test answer for question derivation.", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + + const prevQ = r.capturedUpdateBody.previousQuestion; + expect(typeof prevQ).toBe("string"); + expect(prevQ.length).toBeGreaterThan(0); + expect(prevQ).toBe("Which option leaves us better off overall?"); + }); + + it("exact ANSWER_2 is sent", () => { + const customAnswer = "Relocate. The £2M annual saving justifies the consequences."; + const r = runPreAnchoredSimulationWithFixture({ + answer: customAnswer, + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + + // Direct assertion of exact ANSWER_2 in request body + expect(r.capturedUpdateBody.answer).toBe(customAnswer); + }); + + it("Start calls = 0", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "any answer", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + expect(r.startCalls).toBe(0); + }); + + it("Update calls = 1", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "any answer", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + expect(r.updateCalls).toBe(1); + }); + + it("total calls = 1", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "any answer", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + const totalCalls = r.startCalls + r.updateCalls; + expect(totalCalls).toBe(1); + }); + + it("missing ANSWER_2 = zero calls", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(0); + expect(r.type).toBe("blocked_no_answer"); + expect(r.blockedMessage).toContain("missing ANSWER_2"); + }); + + it("invalid/missing fixture path = zero calls", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "some answer", + initialGraph: null, // signal invalid fixture + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(0); + expect(r.type).toBe("anchor_validation_failed"); + }); + + it("existing savings-realism fixture mode still works", () => { + const r = runPreAnchoredSimulation(); // uses default PRE_ANCHORED_FIXTURE + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + expect(r.type).toBe("all_success"); + + // Verify the default fixture's graph was sent + const updateBody = r.capturedUpdateBody; + const sentNodes = JSON.parse(JSON.stringify(updateBody.situationGraph.nodes)); + const sentEdges = JSON.parse(JSON.stringify(updateBody.situationGraph.edges)); + expect(sentNodes).toEqual(PRE_ANCHORED_FIXTURE.graph.nodes); + expect(sentEdges).toEqual(PRE_ANCHORED_FIXTURE.graph.edges); + }); + + it("normal Start→Update mode unchanged", () => { + const rNormal = runSimulation({ scenario: "test_ok", maxUpdates: 1, answers: ["good answer"] }); + expect(rNormal.startCalls).toBe(1); + expect(rNormal.updateCalls).toBeGreaterThanOrEqual(0); + + const rWithShape = runSimulationWithResponseShape({ + scenario: "test_ok", + maxUpdates: 1, + answers: ["good answer"], + onResponseUpdate: () => ({ + success: true, + stage: "update_applied", + updatedSituationGraph: { nodes: [], edges: [] }, + selectedQuestion: { question: "q2" }, + updatedProposal: { addedNodes: [], addedEdges: [], updatedNodes: [], resolvedUnknownNodeIds: [] }, + }), + }); + expect(rWithShape.startCalls).toBe(1); + expect(rWithShape.updateCalls).toBe(1); + }); + + it("no retry logic introduced", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "any answer", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + }); + const totalCalls = r.startCalls + r.updateCalls; + expect(totalCalls).toBe(1); + expect(r.apiLog.length).toBe(1); + expect(r.type).toBe("all_success"); + + const retryEntries = r.apiLog.filter((e) => e.step === "update" && e.answer?.includes("_retry")); + expect(retryEntries.length).toBe(0); + }); + + it("accepted capture remains unchanged", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "Relocate is best.", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + onResponseUpdate: () => ({ + success: true, + stage: "update_applied", + updatedSituationGraph: { + nodes: [ + { id: "n_relocation_state", kind: "state", label: "relocation consideration", status: "provisional" }, + { id: "opt_relocate", kind: "option", label: "Relocate to Manchester", status: "known" }, + { id: "opt_stay_put", kind: "option", label: "Stay in London (Status Quo)", status: "known" }, + ], + edges: [], + }, + selectedQuestion: { question: "What outcome would demonstrate enough value?", nodeId: "n_relocation_decision" }, + updatedProposal: { + addedNodes: [{ id: "opt_relocate", kind: "option", label: "Relocate to Manchester", description: "Move the engineering team to Manchester.", confidence: "high", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }], + addedEdges: [{ id: "e-opt-rel-to-dec", fromNodeId: "opt_relocate", toNodeId: "n_relocation_decision", relationship: "contained_in", confidence: "high", description: "relocate is contained in the decision" }], + updatedNodes: [], + resolvedUnknownNodeIds: ["n_savings_realism"], + structuralActionRequired: true, + answerMeaning: { + userSupportedMeaning: "Relocation justified by net value.", + possibleInference: null, + supportCategory: "other", + resolutionGuidance: null, + }, + }, + }), + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + expect(r.type).toBe("all_success"); + expect(r.captured.structuralActionRequired).toBe(true); + expect(r.captured.proposal.addedNodes.length).toBe(1); + expect(r.captured.proposal.resolvedUnknownNodeIds.length).toBe(1); + }); + + it("rejectedProposalSnapshot capture remains unchanged", () => { + const r = runPreAnchoredSimulationWithFixture({ + answer: "Relocate is best.", + initialGraph: DECISION_OPTIONS_FIXTURE.graph, + onResponseUpdate: () => ({ + success: false, + stage: "proposal_compatibility", + errors: ["structuralActionRequired=true but zero-mutation"], + diagnostics: { + rejectedProposalSnapshot: { + structuralActionRequired: true, + addedNodes: [], + addedEdges: [], + updatedNodes: [{ nodeId: "nz4k4ep", newValue: null }], + resolvedUnknownNodeIds: [], + userSupportedMeaning: "User believes relocation is justified.", + }, + }, + }), + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + expect(r.type).toBe("update_rejection"); + + const rejectedSnapshot = r.rejectedSnapshot; + expect(rejectedSnapshot.structuralActionRequired).toBe(true); + expect(Array.isArray(rejectedSnapshot.addedNodes)).toBe(true); + expect(rejectedSnapshot.addedNodes.length).toBe(0); + expect(rejectedSnapshot.userSupportedMeaning).toContain("relocation"); + }); + it("pre-anchored fixture uses valid existing graph schema", () => { const g = PRE_ANCHORED_FIXTURE.graph;