Files
confidence-engine/tests/graph/live-focused-deconstruct-experiment-helper.mjs
T

535 lines
22 KiB
JavaScript

/**
* Reusable live-focused-deconstruction experiment helper.
*
* Makes one real focused-deconstruction call using the same production
* semantic operation as /api/focused-investigation/deconstruct:
*
* buildFocusedDeconstructPrompt -> provider.generateReconstruction -> validateFocusedDeconstructSchema
*
* Inputs are the minimum fields required by buildFocusedDeconstructPrompt.
* Relies on environment variables OLLAMA_BASE_URL and OLLAMA_MODEL being set.
*/
import dotenv from "dotenv";
import { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation.js";
import { getProvider } from "@/lib/llm/provider.js";
// Load configured OLLAMA_MODEL / OLLAMA_BASE_URL (same mechanism as all known-good live tests)
dotenv.config({ path: ".env.local" });
// ── shared execution seam (extracted from existing body) ────────────────
/**
* Execute a focused-deconstruct prompt through the canonical live machinery.
*
* Caller supplies: prompt, targetNodeId, optional injected provider.
* This function owns: provider resolution, model invocation, timing, validation, result construction.
*/
async function _executeFocusedPrompt({ prompt, targetNodeId, provider }) {
const actualProvider = provider ?? getProvider();
const ollamaModel = process.env.OLLAMA_MODEL;
if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment");
const startedAt = Date.now();
const raw = await actualProvider.generateReconstruction(prompt, ollamaModel);
const elapsedMs = Date.now() - startedAt;
const validationErrors = validateFocusedDeconstructSchema(raw);
if (validationErrors.length > 0) {
throw new Error(
"Focused deconstruction result did not match expected schema:\n" +
validationErrors.map((e) => " - " + e).join("\n")
);
}
return {
success: true,
targetNodeId,
observations: raw.observations,
uncertainties: raw.uncertainties,
assumptions: raw.assumptions,
relationships: raw.relationships,
possibleFollowUpQuestions: raw.possibleFollowUpQuestions,
elapsedMs,
};
}
// ── existing API (preserved — unchanged behaviour) ──────────────────────
/**
* Run one live focused-deconstruction experiment.
*
* @param {object} params
* @param {string} params.targetNodeId - the target node ID under investigation
* @param {string} params.targetLabel - label of the target node
* @param {string} params.targetDescription - description of the target node
* @param {string} params.centralStatement - the case's central statement
* @param {string} params.question - the exact real production question
* @param {string} params.answer - the exact real production answer
* @param {object} [params.provider] - optional injected provider (for test isolation)
* @returns {object} validated result + timing details
*/
export async function runLiveFocusedDeconstructExperiment(params) {
const { targetNodeId, targetLabel, targetDescription, centralStatement, question, answer, provider } = params;
const prompt = buildFocusedDeconstructPrompt({
targetLabel, targetDescription, centralStatement, question, answer,
});
return _executeFocusedPrompt({ prompt, targetNodeId, provider });
}
// ── new supplied-prompt API ──────────────────────────────────────────────
/**
* Execute an already-built focused-deconstruct prompt through the canonical
* live machinery.
*
* The caller supplies a pre-constructed prompt string; this function owns:
* - configured OLLAMA_MODEL reading
* - provider creation/invocation
* - elapsed timing
* - focused-deconstruct schema validation
* - structured result return
*
* @param {object} params
* @param {string} params.prompt - the already-built prompt to send
* @param {string} [params.targetNodeId] - original graph node ID (preserved in result)
* @param {object} [params.provider] - optional injected provider (for test isolation)
* @returns {object} validated result + timing details
*/
export async function runLiveFocusedPromptExperiment({ prompt, targetNodeId, provider }) {
return _executeFocusedPrompt({ prompt, targetNodeId, provider });
}
// ── minimal frontier result contract (EXP37) ────────────────────────────
/**
* Validate that a focused-deconstruction result contains exactly one
* uncertainty and one follow-up question — the minimal frontier output.
* Returns an array of errors (empty = valid).
*/
function validateMinimalFrontierResult(raw) {
const errors = [];
if (!Array.isArray(raw.uncertainties)) {
errors.push("uncertainties must be an array");
} else if (raw.uncertainties.length !== 1) {
errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length);
} else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") {
errors.push("uncertainties[0] must be a non-empty string");
}
if (!Array.isArray(raw.possibleFollowUpQuestions)) {
errors.push("possibleFollowUpQuestions must be an array");
} else if (raw.possibleFollowUpQuestions.length !== 1) {
errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length);
} else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") {
errors.push("possibleFollowUpQuestions[0] must be a non-empty string");
}
return errors;
}
// ── new frontier API (EXP37 minimal runner) ──────────────────────────────
/**
* Execute an already-built prompt and validate the returned payload
* against the minimal two-field frontier contract.
*
* Returns only: uncertainties, possibleFollowUpQuestions, elapsedMs, success.
* Does NOT require or reconstruct observations, assumptions, relationships,
* or targetNodeId.
*
* @param {object} params
* @param {string} params.prompt - the already-built prompt to send
* @param {object} [params.provider] - optional injected provider (for test isolation)
* @returns {object} validated result + timing details
*/
export async function runLiveFocusedFrontierPromptExperiment({ prompt, provider }) {
const actualProvider = provider ?? getProvider();
const ollamaModel = process.env.OLLAMA_MODEL;
if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment");
const startedAt = Date.now();
const raw = await actualProvider.generateReconstruction(prompt, ollamaModel);
const elapsedMs = Date.now() - startedAt;
const validationErrors = validateMinimalFrontierResult(raw);
if (validationErrors.length > 0) {
throw new Error(
"Focused frontier result did not match expected minimal contract:\n" +
validationErrors.map((e) => " - " + e).join("\n")
);
}
return {
success: true,
uncertainties: raw.uncertainties,
possibleFollowUpQuestions: raw.possibleFollowUpQuestions,
elapsedMs,
};
}
// ── three-field observation-frontier result contract (EXP38) ────────────
/**
* Validate that a focused-deconstruction result contains observations + one
* uncertainty + one follow-up question — the minimal observation-grounded
* frontier output for EXP38.
* Returns an array of errors (empty = valid).
*/
function validateObservationFrontierResult(raw) {
const errors = [];
// observations: required, non-empty array of non-empty strings
if (!Array.isArray(raw.observations)) {
errors.push("observations must be an array");
} else if (raw.observations.length === 0) {
errors.push("observations must contain at least one element");
} else {
for (let i = 0; i < raw.observations.length; i++) {
if (typeof raw.observations[i] !== "string" || raw.observations[i].trim() === "") {
errors.push(`observations[${i}] must be a non-empty string`);
}
}
}
// uncertainties: exactly one
if (!Array.isArray(raw.uncertainties)) {
errors.push("uncertainties must be an array");
} else if (raw.uncertainties.length !== 1) {
errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length);
} else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") {
errors.push("uncertainties[0] must be a non-empty string");
}
// possibleFollowUpQuestions: exactly one
if (!Array.isArray(raw.possibleFollowUpQuestions)) {
errors.push("possibleFollowUpQuestions must be an array");
} else if (raw.possibleFollowUpQuestions.length !== 1) {
errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length);
} else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") {
errors.push("possibleFollowUpQuestions[0] must be a non-empty string");
}
return errors;
}
// ── observation-frontier runner (EXP38) ──────────────────────────────────
/**
* Execute an already-built prompt and validate the returned payload
* against the three-field observation-frontier contract.
*
* Requires: observations (array of non-empty strings), uncertainties (exactly 1),
* possibleFollowUpQuestions (exactly 1).
* Does NOT require assumptions, relationships, or targetNodeId.
*
* @param {object} params
* @param {string} params.prompt - the already-built prompt to send
* @param {object} [params.provider] - optional injected provider (for test isolation)
* @returns {object} validated result + timing details
*/
export async function runLiveFocusedObservationFrontierPromptExperiment({ prompt, provider }) {
const actualProvider = provider ?? getProvider();
const ollamaModel = process.env.OLLAMA_MODEL;
if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment");
const startedAt = Date.now();
const raw = await actualProvider.generateReconstruction(prompt, ollamaModel);
const elapsedMs = Date.now() - startedAt;
const validationErrors = validateObservationFrontierResult(raw);
if (validationErrors.length > 0) {
throw new Error(
"Focused observation-frontier result did not match expected three-field contract:\n" +
validationErrors.map((e) => " - " + e).join("\n")
);
}
return {
success: true,
observations: raw.observations,
uncertainties: raw.uncertainties,
possibleFollowUpQuestions: raw.possibleFollowUpQuestions,
elapsedMs,
};
}
// ── observation+assumption-frontier result contract (EXP46) ──────────────
/**
* Validate that a focused-deconstruction result contains observations (at least one)
* + assumptions (zero or more) + one uncertainty + one follow-up question.
* Returns an array of errors (empty = valid).
*/
function validateObservationAssumptionFrontierResult(raw) {
const errors = [];
// observations: required, at least one non-empty string
if (!Array.isArray(raw.observations)) {
errors.push("observations must be an array");
} else if (raw.observations.length === 0) {
errors.push("observations must contain at least one element");
} else {
for (let i = 0; i < raw.observations.length; i++) {
if (typeof raw.observations[i] !== "string" || raw.observations[i].trim() === "") {
errors.push(`observations[${i}] must be a non-empty string`);
}
}
}
// assumptions: zero or more, each a non-empty string if present
if (!Array.isArray(raw.assumptions)) {
errors.push("assumptions must be an array");
} else {
for (let i = 0; i < raw.assumptions.length; i++) {
if (typeof raw.assumptions[i] !== "string" || raw.assumptions[i].trim() === "") {
errors.push(`assumptions[${i}] must be a non-empty string`);
}
}
}
// uncertainties: exactly one
if (!Array.isArray(raw.uncertainties)) {
errors.push("uncertainties must be an array");
} else if (raw.uncertainties.length !== 1) {
errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length);
} else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") {
errors.push("uncertainties[0] must be a non-empty string");
}
// possibleFollowUpQuestions: exactly one
if (!Array.isArray(raw.possibleFollowUpQuestions)) {
errors.push("possibleFollowUpQuestions must be an array");
} else if (raw.possibleFollowUpQuestions.length !== 1) {
errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length);
} else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") {
errors.push("possibleFollowUpQuestions[0] must be a non-empty string");
}
return errors;
}
// ── observation+assumption-frontier runner (EXP46) ──────────────────────
/**
* Execute an already-built prompt and validate the returned payload
* against the observation+assumption-frontier contract.
*
* Requires: observations (array of non-empty strings with at least one),
* assumptions (zero or more non-empty strings, [] is valid),
* uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1).
* Does NOT require relationships, targetNodeId, or any other fields.
*
* @param {object} params
* @param {string} params.prompt - the already-built prompt to send
* @param {object} [params.provider] - optional injected provider (for test isolation)
* @returns {object} validated result + timing details
*/
export async function runLiveFocusedObservationAssumptionFrontierPromptExperiment({ prompt, provider }) {
const actualProvider = provider ?? getProvider();
const ollamaModel = process.env.OLLAMA_MODEL;
if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment");
const startedAt = Date.now();
const raw = await actualProvider.generateReconstruction(prompt, ollamaModel);
const elapsedMs = Date.now() - startedAt;
const validationErrors = validateObservationAssumptionFrontierResult(raw);
if (validationErrors.length > 0) {
throw new Error(
"Focused observation+assumption-frontier result did not match expected contract:\n" +
validationErrors.map((e) => " - " + e).join("\n")
);
}
return {
success: true,
observations: raw.observations,
assumptions: raw.assumptions,
uncertainties: raw.uncertainties,
possibleFollowUpQuestions: raw.possibleFollowUpQuestions,
elapsedMs,
};
}
// ── re-exports for convenience ──────────────────────────────────────────
export { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema };
// ── relationship-frontier result contract (EXP44) ──────────────────────
/**
* Validate that a focused-deconstruction result contains relationships + one
* uncertainty + one follow-up question — the relationship-frontier output.
* Returns an array of errors (empty = valid).
*/
function validateRelationshipFrontierResult(raw) {
const errors = [];
// relationships: required, at least one non-empty string
if (!Array.isArray(raw.relationships)) {
errors.push("relationships must be an array");
} else if (raw.relationships.length === 0) {
errors.push("relationships must contain at least one element");
} else {
for (let i = 0; i < raw.relationships.length; i++) {
if (typeof raw.relationships[i] !== "string" || raw.relationships[i].trim() === "") {
errors.push(`relationships[${i}] must be a non-empty string`);
}
}
}
// uncertainties: exactly one
if (!Array.isArray(raw.uncertainties)) {
errors.push("uncertainties must be an array");
} else if (raw.uncertainties.length !== 1) {
errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length);
} else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") {
errors.push("uncertainties[0] must be a non-empty string");
}
// possibleFollowUpQuestions: exactly one
if (!Array.isArray(raw.possibleFollowUpQuestions)) {
errors.push("possibleFollowUpQuestions must be an array");
} else if (raw.possibleFollowUpQuestions.length !== 1) {
errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length);
} else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") {
errors.push("possibleFollowUpQuestions[0] must be a non-empty string");
}
return errors;
}
// ── relationship-frontier runner (EXP44) ───────────────────────────────
/**
* Execute an already-built prompt and validate the returned payload
* against the relationship-frontier contract.
*
* Requires: relationships (array of non-empty strings with at least one),
* uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1).
* Does NOT require observations, assumptions, or targetNodeId.
*
* @param {object} params
* @param {string} params.prompt - the already-built prompt to send
* @param {object} [params.provider] - optional injected provider (for test isolation)
* @returns {object} validated result + timing details
*/
export async function runLiveFocusedRelationshipFrontierPromptExperiment({ prompt, provider }) {
const actualProvider = provider ?? getProvider();
const ollamaModel = process.env.OLLAMA_MODEL;
if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment");
const startedAt = Date.now();
const raw = await actualProvider.generateReconstruction(prompt, ollamaModel);
const elapsedMs = Date.now() - startedAt;
const validationErrors = validateRelationshipFrontierResult(raw);
if (validationErrors.length > 0) {
throw new Error(
"Focused relationship-frontier result did not match expected contract:\n" +
validationErrors.map((e) => " - " + e).join("\n")
);
}
return {
success: true,
relationships: raw.relationships,
uncertainties: raw.uncertainties,
possibleFollowUpQuestions: raw.possibleFollowUpQuestions,
elapsedMs,
};
}
// ── assumption-frontier result contract (EXP45) ────────────────────────
/**
* Validate that a focused-deconstruction result contains assumptions (zero or more)
* + one uncertainty + one follow-up question — the assumption-frontier output.
* Returns an array of errors (empty = valid).
*/
function validateAssumptionFrontierResult(raw) {
const errors = [];
// assumptions: zero or more, each a non-empty string if present
if (!Array.isArray(raw.assumptions)) {
errors.push("assumptions must be an array");
} else {
for (let i = 0; i < raw.assumptions.length; i++) {
if (typeof raw.assumptions[i] !== "string" || raw.assumptions[i].trim() === "") {
errors.push(`assumptions[${i}] must be a non-empty string`);
}
}
}
// uncertainties: exactly one
if (!Array.isArray(raw.uncertainties)) {
errors.push("uncertainties must be an array");
} else if (raw.uncertainties.length !== 1) {
errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length);
} else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") {
errors.push("uncertainties[0] must be a non-empty string");
}
// possibleFollowUpQuestions: exactly one
if (!Array.isArray(raw.possibleFollowUpQuestions)) {
errors.push("possibleFollowUpQuestions must be an array");
} else if (raw.possibleFollowUpQuestions.length !== 1) {
errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length);
} else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") {
errors.push("possibleFollowUpQuestions[0] must be a non-empty string");
}
return errors;
}
// ── assumption-frontier runner (EXP45) ────────────────────────────────
/**
* Execute an already-built prompt and validate the returned payload
* against the assumption-frontier contract.
*
* Requires: assumptions (zero or more non-empty strings, [] is valid),
* uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1).
* Does NOT require observations, relationships, or targetNodeId.
*
* @param {object} params
* @param {string} params.prompt - the already-built prompt to send
* @param {object} [params.provider] - optional injected provider (for test isolation)
* @returns {object} validated result + timing details
*/
export async function runLiveFocusedAssumptionFrontierPromptExperiment({ prompt, provider }) {
const actualProvider = provider ?? getProvider();
const ollamaModel = process.env.OLLAMA_MODEL;
if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment");
const startedAt = Date.now();
const raw = await actualProvider.generateReconstruction(prompt, ollamaModel);
const elapsedMs = Date.now() - startedAt;
const validationErrors = validateAssumptionFrontierResult(raw);
if (validationErrors.length > 0) {
throw new Error(
"Focused assumption-frontier result did not match expected contract:\n" +
validationErrors.map((e) => " - " + e).join("\n")
);
}
return {
success: true,
assumptions: raw.assumptions,
uncertainties: raw.uncertainties,
possibleFollowUpQuestions: raw.possibleFollowUpQuestions,
elapsedMs,
};
}