feat(60A.7): add reusable decision-options fixture loading in test harness
- Load decisions-options fixture from committed JSON (tests/fixtures/ pre-anchored-decision-options.json) instead of inline duplicate - Add runPreAnchoredSimulationWithFixture() helper for decision-options mode tests - Generalize anchor validation from savings-realism-specific to generic unresolved unknown check in reproduce-multi-turn-investigation.mjs - Add experiment documentation (experiment-60a7.md) and handoff note - All 63 harness tests pass; no production reasoning code changed
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user