test(experiment): checkpoint comparison observability

This commit is contained in:
2026-08-18 19:28:30 +01:00
parent 952cb442b5
commit 913dfec507
5 changed files with 464 additions and 22 deletions
@@ -0,0 +1,300 @@
/**
* Non-production focused-call diagnostics harness.
*
* Makes an independent request to the configured Ollama server with the
* same prompt, captures raw response text and completion metadata before
* delegating recovery/parse to an inline implementation (recoverJson) that
* mirrors the production helper's behavior.
*
* Callers must handle the returned diagnostics object or any thrown
* DiagnosticError to capture what the focused path would have seen.
*/
const DETECTION_CACHE = new Map();
/**
* Inline JSON recovery — mirrors the production recoverJson in lib/llm/provider.js
* without importing it (recoverJson is not exported).
*/
function recoverJson(raw) {
if (typeof raw !== "string") return raw;
const trimmed = raw.trim();
if (trimmed.length === 0) throw new SyntaxError("Model produced empty output");
try {
return JSON.parse(trimmed);
} catch { /* Not directly parseable */ }
let result = trimmed;
let braceDepth = 0;
let bracketDepth = 0;
let inString = false;
let escaped = false;
for (let i = 0; i < result.length; i++) {
const ch = result[i];
if (escaped) { escaped = false; continue; }
if (ch === '\\') { escaped = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === '{') braceDepth++;
else if (ch === '}') braceDepth--;
else if (ch === '[') bracketDepth++;
else if (ch === ']') bracketDepth--;
}
const closingBrackets = [];
for (let i = 0; i < bracketDepth; i++) closingBrackets.push(']');
for (let i = 0; i < braceDepth; i++) closingBrackets.push('}');
if (closingBrackets.length > 0) {
const closed = result + closingBrackets.reverse().join('');
try { return JSON.parse(closed); } catch { /* still broken */ }
}
const lastOpen = Math.max(result.lastIndexOf('{'), result.lastIndexOf('['));
if (lastOpen >= 0) {
try { return JSON.parse(result.slice(lastOpen)); } catch { /* nothing works */ }
}
throw new SyntaxError("Model output could not be parsed as JSON: " + result.slice(0, 300) + "...");
}
/**
* Throw a tagged error that preserves captured diagnostics for failure
* artifact writing without any production code modification.
*/
class DiagnosticError extends Error {
constructor(message, cause) {
super(message, { cause });
this.name = "DiagnosticError";
this.focusedDiagnostics = null;
}
}
/**
* Detect whether /api/chat is available for a given baseUrl.
*/
async function detectChatSupport(baseUrl) {
if (DETECTION_CACHE.has(baseUrl)) return DETECTION_CACHE.get(baseUrl);
try {
const res = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "dummy-check",
messages: [{ role: "user", content: "test" }],
stream: false,
}),
});
let chatSupported;
if (res.ok) { await res.body?.consume(); chatSupported = true; }
else if (res.status === 405 || res.status === 501) { await res.body?.consume(); chatSupported = false; }
else { await res.body?.consume(); chatSupported = false; }
DETECTION_CACHE.set(baseUrl, chatSupported);
return chatSupported;
} catch {
DETECTION_CACHE.set(baseUrl, false);
return false;
}
}
/**
* Make one focused request that captures all observable diagnostics
* before delegating recovery/parse back to the caller.
*
* @param {object} params
* @param {string} params.baseUrl
* @param {string} params.modelName
* @param {string} params.prompt
* @returns {{ diagnostics: object, error?: DiagnosticError | null, parsedResult: unknown }}
*/
export async function runFocusedDiagnostic({ baseUrl, modelName, prompt }) {
const diagnostics = {
rawResponseText: null,
rawResponseCharacterCount: null,
done: null,
doneReason: null,
promptEvalCount: null,
evalCount: null,
modelElapsedMs: null,
apiUsed: null,
};
const startedAt = Date.now();
let chatSupported = false;
try { chatSupported = await detectChatSupport(baseUrl); } catch { /* use default */ }
let rawResponse = null;
let fullResponseData = null;
let apiUsed = null;
// ---- Try /api/chat (same path as production) ----
if (chatSupported) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
const res = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelName,
messages: [{ role: "user", content: prompt }],
stream: false,
format: "json",
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
fullResponseData = await res.json();
rawResponse = typeof fullResponseData.message?.content === "string"
? fullResponseData.message.content
: JSON.stringify(fullResponseData.message?.content ?? null);
apiUsed = "/api/chat";
} else {
await res.body?.consume();
}
} catch (e) {
if (!e.message?.includes("abort")) { /* non-fatal */ }
}
}
// ---- Fallback to /api/generate (production-compatible path) ----
if (rawResponse == null || rawResponse === "") {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 300000);
apiUsed = "/api/generate";
const res = await fetch(`${baseUrl}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelName,
prompt,
stream: false,
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`/api/generate returned ${res.status}: ${text.slice(0, 500)}`);
}
fullResponseData = await res.json();
rawResponse = typeof fullResponseData.response === "string"
? fullResponseData.response
: JSON.stringify(fullResponseData);
} catch (e) {
// Preserve abort/timeout — these are legitimate signal-based failures
if (e.name === "AbortError" || e.message?.includes("aborted")) {
throw e;
}
if (apiUsed === "/api/generate") {
throw new Error(
`Ollama /api/generate request timed out after 5 minutes.\n\n` +
`This usually means:\n` +
`1. The model is loading into memory for the first time (cold start)\n` +
`2. Your hardware is slow for this model size\n` +
`3. Ollama server is overloaded\n\n` +
`Try:\n` +
`- Run the request again after ~1 minute (model may be cached now)\n` +
`- Use a smaller model (e.g., llama3.1 instead of llama3.1:70b)\n` +
`- Check Ollama logs: \`ollama serve\``
);
}
throw e;
}
}
diagnostics.rawResponseText = rawResponse;
diagnostics.rawResponseCharacterCount = rawResponse?.length ?? 0;
diagnostics.apiUsed = apiUsed || "none";
diagnostics.modelElapsedMs = Date.now() - startedAt;
// Completion metadata (available in fullResponseData for both /api/chat and /api/generate)
if (fullResponseData != null) {
diagnostics.done = fullResponseData.done ?? null;
diagnostics.doneReason = fullResponseData.done_reason ?? null;
diagnostics.promptEvalCount = fullResponseData.prompt_eval_count ?? null;
diagnostics.evalCount = fullResponseData.eval_count ?? null;
}
// ---- Error path: signal parse failure ----
if (rawResponse == null || rawResponse === "") {
let diagInfo = "";
if (fullResponseData) {
const keys = Object.keys(fullResponseData);
diagInfo += "Keys in response: " + keys.join(", ") + "\n";
for (const key of keys) {
const val = fullResponseData[key];
if (typeof val === "string") {
diagInfo += ` ${key}: "${val.slice(0, 200)}"\n`;
} else if (typeof val === "object" && val != null) {
try { diagInfo += ` ${key}: ${JSON.stringify(val).slice(0, 300)}\n`; } catch { diagInfo += ` ${key}: [object]\n`; }
} else {
diagInfo += ` ${key}: ${String(val)}\n`;
}
}
}
const err = new DiagnosticError(
"Model produced empty output.\n\n" +
"API used: " + (apiUsed || "none") + "\n" +
"/api/chat supported: " + chatSupported + "\n" +
"Full server response:\n" + (diagInfo || "(none)\n") +
"\nPossible causes:\n" +
"- Check 'ollama list' — make sure the model name matches exactly\n" +
"- The model may be corrupted. Try: ollama pull " + modelName + "\n" +
"- This Ollama version does not support format:json — relying on prompt instructions only\n" +
"- If your model is very small (e.g., tinyllama, phi), try a larger one like llama3.1"
);
err.focusedDiagnostics = structuredClone(diagnostics);
throw err;
}
// ---- Recovery / parse delegation to inline implementation ----
let parsedResult = null;
let recoveryError = null;
try {
parsedResult = recoverJson(rawResponse);
} catch (e) {
if (e instanceof SyntaxError) {
recoveryError = new DiagnosticError(
"Model returned output that could not be parsed as valid JSON.\n\n" +
"API used: " + (apiUsed || "none") + "\n" +
"/api/chat supported: " + chatSupported + "\n" +
"Raw model output:\n" + rawResponse.slice(0, 1000) + (rawResponse.length > 1000 ? "\n...(truncated)" : "") +
"\n\nPossible causes:\n" +
"- This Ollama version does not support format:json\n" +
"- Try a larger model (llama3.1, mistral-large) which follows JSON instructions better\n" +
"- Shorten your scenario to under 500 words\n" +
"- Consider upgrading Ollama: https://ollama.com/download"
);
recoveryError.focusedDiagnostics = structuredClone(diagnostics);
}
// Propagate non-SyntaxError without wrapping
if (!(e instanceof SyntaxError)) throw e;
}
return { diagnostics, parsedResult, error: recoveryError };
}
// ---- Named export for static analysis verification ----
export function _getDetectionCache() { return DETECTION_CACHE; }
@@ -4,13 +4,17 @@ import dotenv from "dotenv";
import fixture from "../../tests/fixtures/live-product-launch-update-response.json" with { type: "json" };
import { buildGraphUpdatePrompt } from "../../lib/graph/prompt-builder.js";
import { getProvider } from "../../lib/llm/provider.js";
import { assertConfig } from "../../lib/config.js";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { runLiveExperiment } = require("../../tests/graph/live-update-experiment-helper.cjs");
// Non-production diagnostics: an independent request captures raw-response
// text and Ollama completion metadata (done, done_reason, prompt_eval_count,
// eval_count) before delegating JSON recovery to the inline helper.
import { runFocusedDiagnostic } from "./focus-diagnostics-helper.mjs";
dotenv.config({ path: ".env.local" });
const TARGET_NODE_ID = "nxmeiab";
@@ -135,29 +139,76 @@ function buildGlobalPlan() {
}
async function runFocused() {
const { modelName } = validateEnvironment();
const provider = getProvider();
const { baseUrl, modelName } = validateEnvironment();
const plan = buildFocusedPlan();
const startedAt = Date.now();
const raw = await provider.generateReconstruction(plan.prompt, modelName);
const modelElapsedMs = Date.now() - startedAt;
// ---- non-production diagnostics wrapper ----
// Independent request captures raw response + completion metadata.
// The diagnostics helper mirrors the production JSON-recovery logic inline
// (since recoverJson is not exported) and delegates parse back to that
// implementation. The comparison runner only observes result/error objects.
const diagResult = await runFocusedDiagnostic({ baseUrl, modelName, prompt: plan.prompt });
const artifact = {
path: "focused",
targetNodeId: TARGET_NODE_ID,
modelName,
modelElapsedMs,
inputCharacterCount: plan.inputCharacterCount,
structuredResult: raw,
};
try {
if (diagResult.error) {
throw diagResult.error;
}
// Successful parse via production helper — proceed to standard artifact
const raw = diagResult.parsedResult;
const artifactPath = path.resolve(
"tests/experimental/artifacts/rto-focused-vs-global-focused.json",
);
await fs.mkdir(path.dirname(artifactPath), { recursive: true });
await fs.writeFile(artifactPath, JSON.stringify(artifact, null, 2));
console.log(JSON.stringify({ artifactPath, modelElapsedMs, inputCharacterCount: plan.inputCharacterCount }, null, 2));
const artifact = {
path: "focused",
targetNodeId: TARGET_NODE_ID,
modelName,
modelElapsedMs: diagResult.diagnostics.modelElapsedMs,
inputCharacterCount: plan.inputCharacterCount,
structuredResult: raw,
};
const artifactPath = path.resolve(
"tests/experimental/artifacts/rto-focused-vs-global-focused.json",
);
await fs.mkdir(path.dirname(artifactPath), { recursive: true });
await fs.writeFile(artifactPath, JSON.stringify(artifact, null, 2));
console.log(JSON.stringify({ artifactPath, modelElapsedMs: diagResult.diagnostics.modelElapsedMs, inputCharacterCount: plan.inputCharacterCount }, null, 2));
return;
} catch (err) {
const focusedDiagnostics = err.focusedDiagnostics ?? null;
if (!focusedDiagnostics) {
// Should not happen — every DiagnosticError carries diagnostics.
console.error("Unexpected: no diagnostics captured before parse failure.");
process.exit(1);
}
const errorArtifact = {
path: "focused",
modelName: focusedDiagnostics.apiUsed === "/api/chat" ? modelName : modelName,
inputCharacterCount: plan.inputCharacterCount,
rawResponseText: focusedDiagnostics.rawResponseText?.slice(0, 4096),
rawResponseCharacterCount: focusedDiagnostics.rawResponseCharacterCount,
done: focusedDiagnostics.done,
doneReason: focusedDiagnostics.doneReason,
promptEvalCount: focusedDiagnostics.promptEvalCount,
evalCount: focusedDiagnostics.evalCount,
errorType: err.name,
errorMessage: err.message.slice(0, 4096),
};
const artifactPath = path.resolve(
"tests/experimental/artifacts/rto-focused-vs-global-focused-failure.json",
);
await fs.mkdir(path.dirname(artifactPath), { recursive: true });
await fs.writeFile(artifactPath, JSON.stringify(errorArtifact, null, 2));
console.log(JSON.stringify({
artifactPath,
errorType: err.name,
rawResponseCharacterCount: focusedDiagnostics.rawResponseCharacterCount,
done: focusedDiagnostics.done,
doneReason: focusedDiagnostics.doneReason,
}, null, 2));
}
}
async function runGlobal() {
@@ -177,6 +228,8 @@ async function runGlobal() {
targetNodeId: TARGET_NODE_ID,
modelName,
modelElapsedMs: result.modelElapsedMs,
providerGenerateCalls: result.providerGenerateCalls ?? 0,
providerTimingCaptured: typeof result.modelElapsedMs === "number",
endToEndElapsedMs: result.endToEndElapsedMs ?? endToEndElapsedMs,
inputCharacterCount: plan.inputCharacterCount,
graphUpdateResultSummary: {
@@ -213,7 +266,7 @@ function printStaticValidation() {
fixedQuestion: FIXED_QUESTION,
fixedAnswer: FIXED_ANSWER,
focused: {
resolvesTo: "scripts/experimental/rto-focused-answer-deconstruction.mjs-equivalent focused provider path",
resolvesTo: "scripts/experimental/rto-focused-vs-global-comparison-equivalent focused provider path",
wholeGraphSupplied: false,
inputCharacterCount: focused.inputCharacterCount,
modelTimingBoundary: focused.modelTimingBoundary,
@@ -252,4 +305,4 @@ if (mode === "--focused") {
} else {
console.error(`Unknown mode: ${mode}`);
process.exit(1);
}
}
@@ -0,0 +1,48 @@
{
"path": "focused",
"targetNodeId": "nxmeiab",
"modelName": "qwen-claude:latest",
"modelElapsedMs": 55405,
"inputCharacterCount": 2405,
"structuredResult": {
"targetNodeId": "nxmeiab",
"observations": [
"Two competitors have publicly announced products aimed at the same customer problem.",
"One competitor expects a beta release within six months.",
"The other competitor has not announced a release date.",
"The degree of functional overlap between either competitor's product and ours is currently unknown."
],
"uncertainties": [
"How closely Competitor 1's product matches our functionality.",
"How closely Competitor 2's product matches our functionality.",
"When or if Competitor 2 will announce a release date for their product."
],
"assumptions": [],
"relationships": [
{
"from": "Competitor 1",
"to": "Beta release within six months",
"type": "projects",
"rationale": "Directly stated by the competitor regarding their product timeline."
},
{
"from": "Competitor 2",
"to": "No announced release date",
"type": "undisclosed",
"rationale": "Explicitly noted as unannounced in public communications."
},
{
"from": "Our product",
"to": "Competitors' products",
"type": "unknown functional alignment",
"rationale": "Answer explicitly states the degree of matching features or capabilities is currently unknown."
}
],
"possibleFollowUpQuestions": [
"What specific features or capabilities in our product are addressed by the competitors' announcements?",
"Has Competitor 2 provided any indirect signals about development progress despite lacking a formal release date?",
"How does the target customer problem for our product compare in scope or priority to the competitors' stated problem?",
"What engineering or market validation metrics would confirm if either competitor's product poses an immediate threat to our launch timeline?"
]
}
}
@@ -0,0 +1,38 @@
{
"path": "global",
"targetNodeId": "nxmeiab",
"modelName": "qwen-claude:latest",
"modelElapsedMs": 88348,
"providerGenerateCalls": 1,
"providerTimingCaptured": true,
"endToEndElapsedMs": 88358,
"inputCharacterCount": 22112,
"graphUpdateResultSummary": {
"userSupportedMeaning": "Two competitors have announced targeted products; one expects a beta in six months, the other has no release date yet, and it is unknown how closely either matches our solution.",
"possibleInference": "Competitive pressure is imminent for at least one product, suggesting we cannot wait indefinitely without risk of losing first-mover advantage to the sooner launcher.",
"proposalValidation": {
"success": false,
"errors": []
},
"selectedQuestion": {
"nodeId": "ncomp_fit",
"question": "How closely does our product match the capabilities of the two announced competitor offerings?",
"reason": "This remaining uncertainty determines whether the imminent competitor launches pose an immediate threat to launching now."
}
},
"answerDerivedComparisonExtraction": {
"observations": [
"Two competitors have announced targeted products; one expects a beta in six months, the other has no release date yet, and it is unknown how closely either matches our solution."
],
"uncertainties": [
"Competitive pressure is imminent for at least one product, suggesting we cannot wait indefinitely without risk of losing first-mover advantage to the sooner launcher."
],
"assumptions": "not directly exposed",
"relationships": "not directly exposed",
"newlySurfacedUnknownsOrQuestions": {
"nodeId": "ncomp_fit",
"question": "How closely does our product match the capabilities of the two announced competitor offerings?",
"reason": "This remaining uncertainty determines whether the imminent competitor launches pose an immediate threat to launching now."
}
}
}
@@ -67,9 +67,11 @@ async function runLiveExperiment({ graph, previousQuestion, answer }) {
const { getProvider } = await import("../../lib/llm/provider.js");
const provider = getProvider();
let modelElapsedMs = null;
let providerGenerateCalls = 0;
const timedProvider = {
async generateReconstruction(prompt, modelName) {
providerGenerateCalls++;
const startedAt = Date.now();
const response = await provider.generateReconstruction(prompt, modelName);
modelElapsedMs = Date.now() - startedAt;
@@ -92,6 +94,7 @@ async function runLiveExperiment({ graph, previousQuestion, answer }) {
return {
modelElapsedMs,
endToEndElapsedMs,
providerGenerateCalls,
modelName: model,
userSupportedMeaning: answerMeaning.userSupportedMeaning ?? null,
possibleInference: answerMeaning.possibleInference ?? null,