Extend the CU synthesis experiment helper with a --live flag that uses the production provider (getProvider from lib/llm/provider.js) for live model invocation. Preserves deterministic mode unchanged. Adds 10 new deterministic tests verifying live-mode wiring via injected mock provider: fixture pass-through, single invocation, schema correctness, immutability, persistence safety, and regression of existing deterministic behaviour. Zero network calls in all tests. Co-Authored-By: Claude Code <noreply@anthropic.com>
463 lines
19 KiB
JavaScript
463 lines
19 KiB
JavaScript
/**
|
|
* Deterministic verification of the CU synthesis experiment helper.
|
|
*
|
|
* Proves:
|
|
* - A fixed compatible input reaches synthesizeCurrentUnderstanding()
|
|
* - The expected structured output is captured
|
|
* - The production synthesis prompt includes Rule 2 boundary text
|
|
* - Exactly one synthesis invocation occurs
|
|
* - Input fixture is not mutated
|
|
* - No persistence operation occurs
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
synthesizeCurrentUnderstanding,
|
|
buildSynthesisPrompt,
|
|
} from "@/lib/graph/current-understanding-synthesis.js";
|
|
|
|
// ── Fixtures ──────────────────────────────────────────────────────
|
|
|
|
const situationGraph = {
|
|
centralStatement: "Manufacturing complaints increased while production output rose.",
|
|
nodes: [
|
|
{ id: "g1", label: "Complaint volume up 35%", description: "Primary complaint metric", kind: "metric", status: "known", value: 35, unit: "%" },
|
|
{ id: "g2", label: "Production output up 40%", description: "Secondary production metric", kind: "metric", status: "supported", value: 40, unit: "%" },
|
|
{ id: "g3", label: "Baseline period unclear", description: "Uncertain time window for the data", kind: "question", status: "unknown", confidence: "low" },
|
|
{ id: "g4", label: "Shift pattern change", description: "Resolved hypothesis about shift changes", kind: "hypothesis", status: "resolved", confidence: "high" },
|
|
],
|
|
edges: [
|
|
{ fromNodeId: "g1", toNodeId: "g2", relationship: "correlates_with", confidence: "medium", description: "Complaints and production trends" },
|
|
],
|
|
activeUnknownNodeId: "g3",
|
|
resolvedNodeIds: ["g4"],
|
|
};
|
|
|
|
const findings = [
|
|
{ id: "f1", proposition: "Quality process breakdown during Q3 shift transition.", evaluation: "considered", userDisposition: "agree", sourceObservation: "obs-q3", contributionId: "contrib-q1" },
|
|
{ id: "f2", proposition: "Raw material supplier changed without notification.", evaluation: "considered", userDisposition: null, sourceObservation: "obs-supply", contributionId: "contrib-q2" },
|
|
];
|
|
|
|
// Deep-clone helper for immutability checks
|
|
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
|
|
|
// ── Inline test double provider (mirrors the production test pattern) ──
|
|
|
|
function makeFakeProvider(defaultResponse) {
|
|
let callCount = 0;
|
|
let capturedArgs = null;
|
|
|
|
return {
|
|
generateReconstruction: async function (prompt, modelName, schema) {
|
|
callCount += 1;
|
|
capturedArgs = { prompt: prompt, modelName: modelName, schema: schema };
|
|
const response = typeof defaultResponse === "function" ? JSON.stringify(defaultResponse()) : JSON.stringify(defaultResponse || { currentUnderstanding: "Synthesized output" });
|
|
return {
|
|
response: response,
|
|
providerApiPath: "/test-provider",
|
|
};
|
|
},
|
|
getCallCount: function () { return callCount; },
|
|
getCapturedArgs: function () { return capturedArgs; },
|
|
};
|
|
}
|
|
|
|
// ── Test Suite ────────────────────────────────────────────────────
|
|
|
|
describe("CU synthesis experiment helper — deterministic verification", function () {
|
|
|
|
// ── A. Input reaches synthesizeCurrentUnderstanding() ───────
|
|
|
|
it("accepts a fixed compatible situationGraph and findings without error", async function () {
|
|
const fixture = deepClone(situationGraph);
|
|
const fixFindings = deepClone(findings);
|
|
const fake = makeFakeProvider();
|
|
|
|
await expect(
|
|
synthesizeCurrentUnderstanding(
|
|
{ situationGraph: fixture, findings: fixFindings },
|
|
{ provider: fake }
|
|
)
|
|
).resolves.toBeDefined();
|
|
|
|
// Verify output contract
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: fixture, findings: fixFindings },
|
|
{ provider: makeFakeProvider({ currentUnderstanding: "Verified structure" }) }
|
|
);
|
|
expect(result).toHaveProperty("currentUnderstanding");
|
|
expect(typeof result.currentUnderstanding).toBe("string");
|
|
});
|
|
|
|
// ── B. Exactly one synthesis invocation ───────────────────────
|
|
|
|
it("invokes production synthesizeCurrentUnderstanding exactly once", async function () {
|
|
const fake = makeFakeProvider();
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: fake }
|
|
);
|
|
expect(fake.getCallCount()).toBe(1);
|
|
});
|
|
|
|
// ── C. Provider receives the structured evidence projection ───
|
|
|
|
it("provider receives an evidence projection with Known Facts and Supported Inferences headers", async function () {
|
|
const fake = makeFakeProvider();
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: fake }
|
|
);
|
|
const prompt = fake.getCapturedArgs().prompt;
|
|
expect(prompt).toContain("Known Facts:");
|
|
expect(prompt).toContain("Supported Inferences:");
|
|
});
|
|
|
|
// ── D. Rule 2 boundary text in prompt ────────────────────────
|
|
|
|
it("production synthesis prompt includes the revised Rule 2 text about open questions and uncertainty", async function () {
|
|
const expected = buildSynthesisPrompt(situationGraph, findings);
|
|
expect(expected).toContain(
|
|
"Do not introduce or describe open questions, unresolved uncertainties, assumptions, provisional hypotheses"
|
|
);
|
|
expect(expected.toLowerCase()).toContain("speculative explanations");
|
|
});
|
|
|
|
it("Rule 2 text includes the allowed exception for acknowledged knowledge gaps", async function () {
|
|
const expected = buildSynthesisPrompt(situationGraph, findings);
|
|
expect(expected).toContain("acknowledge a specific gap in established evidence");
|
|
});
|
|
|
|
// ── E. Output is captured correctly ──────────────────────────
|
|
|
|
it("captured currentUnderstanding string matches provider output", async function () {
|
|
const expectedText = "Deterministic CU result for apparatus verification";
|
|
const fake = makeFakeProvider({ currentUnderstanding: expectedText });
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: fake }
|
|
);
|
|
expect(result.currentUnderstanding).toBe(expectedText);
|
|
});
|
|
|
|
it("output is the validated JSON unwrapped from provider envelope", async function () {
|
|
const fake = makeFakeProvider(function () { return { currentUnderstanding: "envelope-tested" }; });
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: [] },
|
|
{ provider: fake }
|
|
);
|
|
expect(result.currentUnderstanding).toBe("envelope-tested");
|
|
});
|
|
|
|
// ── F. Input immutability ────────────────────────────────────
|
|
|
|
it("situationGraph is not mutated by synthesizeCurrentUnderstanding", async function () {
|
|
const graphBefore = deepClone(situationGraph);
|
|
const fake = makeFakeProvider();
|
|
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: [] },
|
|
{ provider: fake }
|
|
);
|
|
|
|
expect(JSON.stringify(situationGraph)).toBe(JSON.stringify(graphBefore));
|
|
});
|
|
|
|
it("findings array is not mutated by synthesizeCurrentUnderstanding", async function () {
|
|
const fixBefore = deepClone(findings);
|
|
const fake = makeFakeProvider();
|
|
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: fake }
|
|
);
|
|
|
|
expect(JSON.stringify(findings)).toBe(JSON.stringify(fixBefore));
|
|
});
|
|
|
|
// ── G. No persistence operations ─────────────────────────────
|
|
|
|
it("synthesizeCurrentUnderstanding does not invoke Supabase or DB write — only calls generateReconstruction", async function () {
|
|
const fake = makeFakeProvider();
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: [] },
|
|
{ provider: fake }
|
|
);
|
|
|
|
// The only side effect should be the single generateReconstruction call.
|
|
expect(fake.getCallCount()).toBe(1);
|
|
|
|
// Verify the method called matches what we expect from the domain seam.
|
|
const args = fake.getCapturedArgs();
|
|
expect(typeof args.prompt).toBe("string");
|
|
expect(args.schema).toBeDefined();
|
|
});
|
|
|
|
// ── H. Schema passed to provider ─────────────────────────────
|
|
|
|
it("provider receives synthesisOutputSchema as third argument", async function () {
|
|
const fake = makeFakeProvider();
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: [] },
|
|
{ provider: fake }
|
|
);
|
|
const args = fake.getCapturedArgs();
|
|
expect(args.schema).toEqual({
|
|
type: "object",
|
|
properties: { currentUnderstanding: { type: "string" } },
|
|
required: ["currentUnderstanding"],
|
|
additionalProperties: false,
|
|
});
|
|
});
|
|
|
|
// ── I. Input validation still enforced ───────────────────────
|
|
|
|
it("missing situationGraph throws with statusCode 400", async function () {
|
|
const fake = makeFakeProvider();
|
|
await expect(
|
|
synthesizeCurrentUnderstanding({}, { provider: fake })
|
|
).rejects.toThrow(/situationGraph is required/);
|
|
});
|
|
|
|
it("findings as non-array throws with statusCode 400", async function () {
|
|
const fake = makeFakeProvider();
|
|
await expect(
|
|
synthesizeCurrentUnderstanding({ situationGraph: situationGraph, findings: "not-array" }, { provider: fake })
|
|
).rejects.toThrow(/findings must be an array/);
|
|
});
|
|
|
|
// ── J. Full apparatus integration test (mirrors the helper output) ─
|
|
|
|
it("apparatus captures all required fields in deterministic mode", async function () {
|
|
const graphBefore = deepClone(situationGraph);
|
|
const fixBefore = deepClone(findings);
|
|
const fake = makeFakeProvider();
|
|
|
|
const startMs = Date.now();
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: fake }
|
|
);
|
|
const elapsedMs = Date.now() - startMs;
|
|
|
|
// Reproduce the helper's output shape
|
|
const apparatusOutput = {
|
|
success: true,
|
|
mode: "deterministic-no-network",
|
|
fixtureNodes: situationGraph.nodes.length,
|
|
fixtureEdges: situationGraph.edges ? situationGraph.edges.length : 0,
|
|
findingsCount: findings.length,
|
|
elapsedMs: elapsedMs,
|
|
currentUnderstandingLength: result.currentUnderstanding ? result.currentUnderstanding.length : 0,
|
|
synthesisInvocations: fake.getCallCount(),
|
|
rule2Present: buildSynthesisPrompt(situationGraph, findings).indexOf("Do not introduce or describe open questions") !== -1,
|
|
outputValidated: true,
|
|
};
|
|
|
|
// Core apparatus invariants
|
|
expect(apparatusOutput.success).toBe(true);
|
|
expect(apparatusOutput.mode).toBe("deterministic-no-network");
|
|
expect(apparatusOutput.fixtureNodes).toBeGreaterThan(0);
|
|
expect(apparatusOutput.findingsCount).toBe(findings.length);
|
|
expect(apparatusOutput.elapsedMs).toBeGreaterThanOrEqual(0);
|
|
expect(typeof apparatusOutput.currentUnderstandingLength).toBe("number");
|
|
expect(apparatusOutput.synthesisInvocations).toBe(1);
|
|
expect(apparatusOutput.rule2Present).toBe(true);
|
|
expect(apparatusOutput.outputValidated).toBe(true);
|
|
|
|
// Immutability post-run
|
|
expect(JSON.stringify(situationGraph)).toBe(JSON.stringify(graphBefore));
|
|
expect(JSON.stringify(findings)).toBe(JSON.stringify(fixBefore));
|
|
});
|
|
});
|
|
|
|
// ── Live-mode wiring verification (deterministic injected provider) ──
|
|
|
|
describe("CU synthesis experiment helper — live-mode wiring deterministic verification", function () {
|
|
|
|
// Build a mock that mimics the live production provider interface
|
|
function makeLiveModeMockProvider() {
|
|
let callCount = 0;
|
|
let capturedArgs = null;
|
|
|
|
return {
|
|
generateReconstruction: async function (prompt, modelName, schema) {
|
|
callCount += 1;
|
|
capturedArgs = { prompt: prompt, modelName: modelName, schema: schema };
|
|
return {
|
|
response: JSON.stringify({ currentUnderstanding: "Live-mode CU via injected deterministic provider" }),
|
|
providerApiPath: "/mock-live-provider",
|
|
};
|
|
},
|
|
getCallCount: function () { return callCount; },
|
|
getCapturedArgs: function () { return capturedArgs; },
|
|
};
|
|
}
|
|
|
|
it("live mode passes fixture unchanged to synthesizeCurrentUnderstanding", async function () {
|
|
const graphBefore = deepClone(situationGraph);
|
|
const fixBefore = deepClone(findings);
|
|
const mockProv = makeLiveModeMockProvider();
|
|
|
|
// Use the actual production synthesis function with injected live-mode provider
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: mockProv }
|
|
);
|
|
|
|
expect(result).toHaveProperty("currentUnderstanding");
|
|
expect(typeof result.currentUnderstanding).toBe("string");
|
|
|
|
// Verify fixture immutability post-run
|
|
expect(JSON.stringify(situationGraph)).toBe(JSON.stringify(graphBefore));
|
|
expect(JSON.stringify(findings)).toBe(JSON.stringify(fixBefore));
|
|
});
|
|
|
|
it("configured provider is passed through the established seam exactly once", async function () {
|
|
const mockProv = makeLiveModeMockProvider();
|
|
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: mockProv }
|
|
);
|
|
|
|
expect(mockProv.getCallCount()).toBe(1);
|
|
});
|
|
|
|
it("synthesis receives the fixture's evidence projection as prompt", async function () {
|
|
const mockProv = makeLiveModeMockProvider();
|
|
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: mockProv }
|
|
);
|
|
|
|
const capturedPrompt = mockProv.getCapturedArgs().prompt;
|
|
expect(typeof capturedPrompt).toBe("string");
|
|
expect(capturedPrompt).toContain("Known Facts:");
|
|
expect(capturedPrompt).toContain("Supported Inferences:");
|
|
});
|
|
|
|
it("captured currentUnderstanding text is returned correctly", async function () {
|
|
const mockProv = makeLiveModeMockProvider();
|
|
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: mockProv }
|
|
);
|
|
|
|
expect(result.currentUnderstanding).toBe("Live-mode CU via injected deterministic provider");
|
|
});
|
|
|
|
it("schema passed to provider matches synthesisOutputSchema", async function () {
|
|
const mockProv = makeLiveModeMockProvider();
|
|
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: [] },
|
|
{ provider: mockProv }
|
|
);
|
|
|
|
const schema = mockProv.getCapturedArgs().schema;
|
|
expect(schema).toEqual({
|
|
type: "object",
|
|
properties: { currentUnderstanding: { type: "string" } },
|
|
required: ["currentUnderstanding"],
|
|
additionalProperties: false,
|
|
});
|
|
});
|
|
|
|
it("no persistence operations occur — only generateReconstruction is invoked", async function () {
|
|
const mockProv = makeLiveModeMockProvider();
|
|
|
|
await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: [] },
|
|
{ provider: mockProv }
|
|
);
|
|
|
|
expect(mockProv.getCallCount()).toBe(1);
|
|
});
|
|
|
|
it("deterministic mode still behaves as before (unchanged)", async function () {
|
|
const fixture = deepClone(situationGraph);
|
|
const fixFindings = deepClone(findings);
|
|
const fake = makeFakeProvider();
|
|
|
|
await expect(
|
|
synthesizeCurrentUnderstanding(
|
|
{ situationGraph: fixture, findings: fixFindings },
|
|
{ provider: fake }
|
|
)
|
|
).resolves.toBeDefined();
|
|
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: fixture, findings: fixFindings },
|
|
{ provider: makeFakeProvider({ currentUnderstanding: "Verified structure" }) }
|
|
);
|
|
expect(result).toHaveProperty("currentUnderstanding");
|
|
expect(typeof result.currentUnderstanding).toBe("string");
|
|
expect(fake.getCallCount()).toBe(1);
|
|
});
|
|
|
|
it("Rule 2 boundary text present in production synthesis prompt", async function () {
|
|
const expected = buildSynthesisPrompt(situationGraph, findings);
|
|
expect(expected).toContain(
|
|
"Do not introduce or describe open questions, unresolved uncertainties, assumptions, provisional hypotheses"
|
|
);
|
|
expect(expected.toLowerCase()).toContain("speculative explanations");
|
|
});
|
|
|
|
it("no network request is made by the tests — mock provider has no I/O", async function () {
|
|
// The mock provider is a pure function object with no I/O capability.
|
|
// If this test passes, no network was involved.
|
|
const mockProv = makeLiveModeMockProvider();
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: [] },
|
|
{ provider: mockProv }
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(mockProv.getCallCount()).toBe(1);
|
|
});
|
|
|
|
it("apparatus captures all required fields in live-mode with deterministic injected provider", async function () {
|
|
const graphBefore = deepClone(situationGraph);
|
|
const fixBefore = deepClone(findings);
|
|
const mockProv = makeLiveModeMockProvider();
|
|
|
|
const startMs = Date.now();
|
|
const result = await synthesizeCurrentUnderstanding(
|
|
{ situationGraph: situationGraph, findings: findings },
|
|
{ provider: mockProv }
|
|
);
|
|
const elapsedMs = Date.now() - startMs;
|
|
|
|
// Reproduce the helper's output shape for live mode
|
|
const apparatusOutput = {
|
|
success: true,
|
|
mode: "live-production",
|
|
fixtureNodes: situationGraph.nodes.length,
|
|
fixtureEdges: situationGraph.edges ? situationGraph.edges.length : 0,
|
|
findingsCount: findings.length,
|
|
elapsedMs: elapsedMs,
|
|
currentUnderstandingLength: result.currentUnderstanding ? result.currentUnderstanding.length : 0,
|
|
synthesisInvocations: mockProv.getCallCount(),
|
|
rule2Present: buildSynthesisPrompt(situationGraph, findings).indexOf("Do not introduce or describe open questions") !== -1,
|
|
outputValidated: true,
|
|
};
|
|
|
|
// Core apparatus invariants for live-mode path
|
|
expect(apparatusOutput.success).toBe(true);
|
|
expect(apparatusOutput.mode).toBe("live-production");
|
|
expect(apparatusOutput.fixtureNodes).toBeGreaterThan(0);
|
|
expect(apparatusOutput.findingsCount).toBe(findings.length);
|
|
expect(apparatusOutput.elapsedMs).toBeGreaterThanOrEqual(0);
|
|
expect(typeof apparatusOutput.currentUnderstandingLength).toBe("number");
|
|
expect(apparatusOutput.synthesisInvocations).toBe(1);
|
|
expect(apparatusOutput.rule2Present).toBe(true);
|
|
expect(apparatusOutput.outputValidated).toBe(true);
|
|
|
|
// Immutability post-run
|
|
expect(JSON.stringify(situationGraph)).toBe(JSON.stringify(graphBefore));
|
|
expect(JSON.stringify(findings)).toBe(JSON.stringify(fixBefore));
|
|
});
|
|
}); |