experiment(confidence-engine): expose reconstruction provider seam

This commit is contained in:
2026-09-06 08:01:29 +01:00
parent 860ee6fc5b
commit 1daf2bb6ce
7 changed files with 115 additions and 13 deletions
+6
View File
@@ -122,6 +122,12 @@
- An experiment-only OpenAI Responses API provider apparatus exists for `gpt-5.6-terra`, using the same current v0.5 prompt and canonical reconstruction schema. Production provider selection remains Ollama/Qwen.
- No live OpenAI calls have occurred. Next boundary: a bounded live semantic comparison, not production migration.
## Reconstruction provider injection seam
- OpenAI provider apparatus exists at `860ee6f`; the canonical helper was previously blocked because `startCase()` and `analyseScenario()` created providers internally.
- An optional reconstruction-provider injection seam now carries an experiment provider through the same production analysis path. Production callers still default to configured Ollama/Qwen through `getProvider()`.
- No live calls occurred. Next boundary: exactly one live OpenAI fixed-scenario run through the canonical helper; this is not a production provider migration.
## Current product architecture
Three distinct routes, not a single page:
+8 -5
View File
@@ -23,6 +23,8 @@ const MAX_SCENARIO_LENGTH = 10000;
* @param {string} scenario - The scenario text to analyse
* @param {object} [opts]
* @param {"v0.1" | "v0.2"} [opts.promptVersion="v0.2"] - Prompt version to use
* @param {{ generateReconstruction: Function }} [opts.reconstructionProvider] - Experiment-only reconstruction provider override
* @param {string} [opts.reconstructionModelName] - Experiment-only model override for an injected provider
* @returns {Promise<object>} Analysis result with diagnostics
*/
export async function analyseScenario(scenario, opts = {}) {
@@ -70,14 +72,15 @@ export async function analyseScenario(scenario, opts = {}) {
}
// ── Call provider ──────────────────────────────────
const provider = getProvider();
const provider = opts.reconstructionProvider ?? getProvider();
const reconstructionModelName = opts.reconstructionModelName ?? OLLAMA_MODEL;
let rawResponse;
let providerApiPath;
let providerExecution;
try {
const providerResult = await provider.generateReconstruction(
promptObj.prompt,
OLLAMA_MODEL,
reconstructionModelName,
);
if (
providerResult &&
@@ -122,7 +125,7 @@ export async function analyseScenario(scenario, opts = {}) {
if (resultV2.valid) {
return buildSuccessResultV2(
resultV2.data,
OLLAMA_MODEL,
reconstructionModelName,
duration,
promptVersion,
compatibility,
@@ -137,7 +140,7 @@ export async function analyseScenario(scenario, opts = {}) {
if (resultV1.valid) {
return buildSuccessResultV1(
resultV1.data,
OLLAMA_MODEL,
reconstructionModelName,
duration,
promptVersion,
compatibility,
@@ -148,7 +151,7 @@ export async function analyseScenario(scenario, opts = {}) {
return buildPartialResult(
rawResponseStr,
resultV2.error ?? resultV1.error,
OLLAMA_MODEL,
reconstructionModelName,
duration,
promptVersion,
compatibility,
+8 -1
View File
@@ -359,7 +359,14 @@ export async function startCase(body, dependencies = {}) {
}
const { scenario, promptVersion } = parsedRequest.data;
const analysis = await analyseScenario(scenario, { promptVersion });
const analysisOptions = { promptVersion };
if (dependencies.reconstructionProvider) {
analysisOptions.reconstructionProvider = dependencies.reconstructionProvider;
}
if (dependencies.reconstructionModelName) {
analysisOptions.reconstructionModelName = dependencies.reconstructionModelName;
}
const analysis = await analyseScenario(scenario, analysisOptions);
if (!analysis.success) {
return {
+16 -7
View File
@@ -53,7 +53,7 @@ function readScenarioInput(argv) {
return { scenario: argv.slice(startIdx).join(" ") };
}
async function runStartCaseExperiment(scenarioInput, experimentInstruction) {
async function runStartCaseExperiment(scenarioInput, experimentInstruction, options = {}) {
// ── Experiment seam bridge: supply instruction to production path ──
const hadEnvVar = process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION != null;
const previousEnvValue = process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION;
@@ -61,10 +61,10 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) {
process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION = experimentInstruction;
}
let startCase;
let startCase = options.startCase;
const isMock = process.env.START_CASE_EXPERIMENT_HELPER_MOCK === "1";
if (isMock) {
if (!startCase && isMock) {
// Deterministic mode: skip environment checks and use inline test double.
startCase = async (body) => {
// Deterministic inline test double — no live model calls.
@@ -81,7 +81,7 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) {
diagnostics: { validationStatus: "mock", modelName: "inline-test-double", graphReferenceValidation: { valid: true, errors: [] } },
};
};
} else {
} else if (!startCase) {
const baseUrl = assertRequiredEnv("OLLAMA_BASE_URL");
const model = assertRequiredEnv("OLLAMA_MODEL");
@@ -99,7 +99,10 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) {
let result;
const endToEndStartedAt = Date.now();
try {
result = await startCase(scenarioInput);
result = await startCase(scenarioInput, {
reconstructionProvider: options.reconstructionProvider,
reconstructionModelName: options.reconstructionModelName,
});
} finally {
// Clean up experiment env var after execution regardless of outcome
if (experimentInstruction && !hadEnvVar) {
@@ -132,7 +135,8 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) {
};
}
(async () => {
if (require.main === module) {
(async () => {
// Import-only mode: prove real production seam without invoking startCase.
// Used only for deterministic apparatus verification of the import path.
if (process.env.START_CASE_EXPERIMENT_HELPER_IMPORT_ONLY === "1") {
@@ -183,4 +187,9 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) {
console.log(JSON.stringify(failure));
process.exit(1);
}
})();
})();
}
module.exports = { runStartCaseExperiment };
module.exports = { runStartCaseExperiment };
+18
View File
@@ -356,6 +356,24 @@ describe("lib/graph/orchestrator startCase", () => {
);
});
it("forwards an injected reconstruction provider to analyseScenario", async () => {
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
const reconstructionProvider = { generateReconstruction: vi.fn() };
const { startCase } = await import("@/lib/graph/orchestrator.js");
const result = await startCase(
{ scenario: "Scenario text" },
{ reconstructionProvider, reconstructionModelName: "experiment-model" },
);
expect(result.success).toBe(true);
expect(mockAnalyseScenario).toHaveBeenCalledWith("Scenario text", {
promptVersion: undefined,
reconstructionProvider,
reconstructionModelName: "experiment-model",
});
});
it("rejects invalid request input without throwing", async () => {
const { startCase } = await import("@/lib/graph/orchestrator.js");
@@ -112,6 +112,35 @@ describe("normaliseAnalysisResponse", () => {
});
describe("analyseScenario compatibility", () => {
it("uses an injected reconstruction provider with the production prompt path", async () => {
const injectedProvider = {
generateReconstruction: vi.fn().mockResolvedValue({
inputClassification: { primaryType: "other", classificationReason: "test", confidence: "low" },
reconstruction: {
summary: "summary", actors: [], systemsOrObjects: [], expectedStates: [], observedStates: [],
differences: [], knownTransitions: [], unexplainedTransitions: [], contradictions: [],
importantUnknowns: [], plausibleInterpretations: [],
},
evidence: [],
nextQuestion: { id: "q1", question: "What next?", targets: [], reason: "test", expectedInformationValue: "low" },
}),
};
const { analyseScenario } = await import("@/lib/analysis.js");
const result = await analyseScenario("Scenario text", {
promptVersion: "v0.3",
reconstructionProvider: injectedProvider,
reconstructionModelName: "experiment-model",
});
expect(result.success).toBe(true);
expect(injectedProvider.generateReconstruction).toHaveBeenCalledWith(
"prompt",
"experiment-model",
);
expect(mockGenerateReconstruction).not.toHaveBeenCalled();
});
it("preserves an attempted provider API path on provider failure", async () => {
const providerError = new Error("Provider failed");
providerError.providerApiPath = "/api/chat";
@@ -14,10 +14,12 @@ import { execFile } from "child_process";
import { writeFile, unlink } from "fs/promises";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { createRequire } from "module";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..", "..");
const helperPath = join(rootDir, "scripts", "start-case-experiment-helper.cjs");
const require = createRequire(import.meta.url);
function runHelper(args = [], envOverrides = {}) {
return new Promise((resolve, reject) => {
@@ -128,6 +130,34 @@ describe("start-case-experiment-helper.cjs apparatus", () => {
// ── D — Success output ──────────────────────────────────────────
describe("D — success output", () => {
it("forwards an explicit reconstruction provider through the startCase path", async () => {
const { runStartCaseExperiment } = require(helperPath);
const reconstructionProvider = { generateReconstruction() {} };
const startCase = async (input, dependencies) => {
expect(input).toEqual({ scenario: "Provider seam scenario" });
expect(dependencies).toMatchObject({
reconstructionProvider,
reconstructionModelName: "gpt-5.6-terra",
});
return {
success: true,
situationGraph: { nodes: [], edges: [] },
assessment: null,
selectedQuestion: null,
summary: "test",
};
};
const result = await runStartCaseExperiment(
{ scenario: "Provider seam scenario" },
null,
{ reconstructionProvider, reconstructionModelName: "gpt-5.6-terra", startCase },
);
expect(result.success).toBe(true);
expect(result.summary).toBe("test");
});
it("exit code is 0", async () => {
const result = await runHelper(["Success test scenario"]);
expect(result.exitCode).toBe(0);