import { describe, it, expect } from "vitest"; import { config } from "dotenv"; import path from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); config({ path: path.resolve(__dirname, "../../.env.local") }); const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL; const OLLAMA_MODEL = process.env.OLLAMA_MODEL; if (!OLLAMA_BASE_URL || !OLLAMA_MODEL) { throw new Error("OLLAMA_BASE_URL and OLLAMA_MODEL must be set in .env.local"); } /** * Make one live Ollama chat call: identify the specific user-owned * distinction that remains unresolved when clarification is required. * Uses the exact Experiment 54S instruction unchanged. */ async function callClarificationTarget(source, disagreement, requiresUserClarification) { const instruction = `Identify the specific unresolved distinction that only the user can clarify. If clarification is required (requiresUserClarification: true), return the smallest statement of the missing user-owned meaning, preference, priority, constraint, definition, or private fact. If clarification is not required (requiresUserClarification: false), return null. Do not write a question. Do not add evidence needs. Do not select a preferred interpretation. Return valid JSON only in this shape: { "clarificationTarget": "short statement" | null }`; const messages = [ { role: "system", content: instruction.trim() }, { role: "user", content: `Source: ${JSON.stringify(source)} Disagreement: ${disagreement.map((d, i) => `${i + 1}. ${d}`).join("\n")} requiresUserClarification: ${requiresUserClarification}`, }, ]; const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: OLLAMA_MODEL, messages, format: "json", stream: false, }), }); if (!res.ok) { throw new Error(`Ollama API error: ${res.status} ${res.statusText}`); } const data = await res.json(); const rawContent = data.message?.content ?? ""; const cleaned = rawContent.replace(/```(?:json)?\s*/g, "").replace(/```\s*/g, ""); return JSON.parse(cleaned.trim()); } // ────────────────────────────────────────────── // Human reference for Case B semantic review // ────────────────────────────────────────────── const HUMAN_REF_CASE_B = { expectedDistinction: "whether avoiding additional risk is a preference/trade-off or a hard constraint", requiredConcepts: [ "risk", "constraint", "preference", "trade-off", "avoiding", "additional", "hard", ], forbiddenConcepts: ["evidence", "investigate", "check", "look at", "data"], }; function evaluateCaseBTarget(target) { if (target == null) return { classification: "null", target }; if (typeof target !== "string" || !target.trim()) return { classification: "null", target }; const t = target.trim().toLowerCase(); if (t.endsWith("?")) return { classification: "null", target }; // Semantic check against human reference const reqConcepts = HUMAN_REF_CASE_B.requiredConcepts; const hasRequired = reqConcepts.some( (c) => t.includes(c.toLowerCase()) ); const forbiddenConcepts = HUMAN_REF_CASE_B.forbiddenConcepts; const hasForbidden = forbiddenConcepts.some( (c) => t.includes(c.toLowerCase()) ); if (!hasRequired || hasForbidden) { return { classification: "target_incorrect", target }; } return { classification: "target_correct", target }; } // ────────────────────────────────────────────── // Fixed cases // ────────────────────────────────────────────── const CASE_A = { id: "Case A — Evidence-Resolvable / False", source: "Orders are arriving late and customers have started complaining.", disagreement: [ "delays may be caused by insufficient staff capacity", "delays may be caused by unreliable supplier lead times", ], requiresUserClarification: false, expectedNull: true, }; const CASE_B = { id: "Case B — User-Owned Ambiguity / True Control", source: "I want the business to grow, but I don't want to take on more risk.", disagreement: [ "growth should be prioritised even if some additional risk is unavoidable", "avoiding additional risk is a hard constraint even if growth is slower", ], requiresUserClarification: true, expectedNull: false, }; const RUNS_PER_CASE = 3; // ────────────────────────────────────────────── // Test suite — Case A (false → null stability) // ────────────────────────────────────────────── describe("Experiment 54T — Clarification Null Stability", () => { const results = { a: [], b: [] }; const timings = []; describe("Case A — Evidence-Resolvable / False (repeated " + RUNS_PER_CASE + "×)", () => { for (let i = 0; i < RUNS_PER_CASE; i++) { it(`run ${i + 1}`, async () => { const start = Date.now(); const result = await callClarificationTarget( CASE_A.source, CASE_A.disagreement, CASE_A.requiresUserClarification ); const elapsed = Date.now() - start; timings.push({ caseId: CASE_A.id, run: i + 1, ms: elapsed }); const isNull = result.clarificationTarget == null; results.a.push({ run: i + 1, result, isNull }); // Structural check expect(result.clarificationTarget).toBeDefined(); // Null-gating: false → null expected if (CASE_A.expectedNull) { console.log( `[54T Case A run ${i + 1}] clarificationTarget: ${isNull ? "null ✓" : `"${result.clarificationTarget}" ✗`}` ); expect(isNull).toBe(true); } else { expect(isNull).toBe(false); } }, 120000); } }); describe("Case B — User-Owned Ambiguity / True Control (repeated " + RUNS_PER_CASE + "×)", () => { for (let i = 0; i < RUNS_PER_CASE; i++) { it(`run ${i + 1}`, async () => { const start = Date.now(); const result = await callClarificationTarget( CASE_B.source, CASE_B.disagreement, CASE_B.requiresUserClarification ); const elapsed = Date.now() - start; timings.push({ caseId: CASE_B.id, run: i + 1, ms: elapsed }); const ev = evaluateCaseBTarget(result.clarificationTarget); results.b.push({ run: i + 1, result, classification: ev.classification }); // Structural check expect(result.clarificationTarget).toBeDefined(); console.log( `[54T Case B run ${i + 1}] classification: ${ev.classification} | target: "${result.clarificationTarget ?? "null"}"` ); // true → non-null expected if (CASE_B.expectedNull) { expect(ev.classification).not.toBe("null"); } else { // This case expects a non-null correct target expect(ev.classification).toBe("target_correct"); } }, 120000); } }); // ────────────────────────────────────────────── // Aggregate summary // ────────────────────────────────────────────── it("54T aggregate results", () => { const aNull = results.a.filter((r) => r.isNull).length; const aNonNull = results.a.filter((r) => !r.isNull).length; const bCorrect = results.b.filter((r) => r.classification === "target_correct").length; const bIncorrect = results.b.filter((r) => r.classification === "target_incorrect").length; const bNull = results.b.filter((r) => r.classification === "null").length; const totalMs = timings.reduce((s, t) => s + t.ms, 0); const avgMs = totalMs / timings.length; const fastMs = Math.min(...timings.map((t) => t.ms)); const slowMs = Math.max(...timings.map((t) => t.ms)); console.log("\n=== Experiment 54T Aggregate Results ==="); console.log(`\n--- Case A (false → null) ---`); for (const r of results.a) { console.log( `Run ${r.run}: ${r.isNull ? "null" : `"${r.result.clarificationTarget}"`}` ); } console.log(`Null: ${aNull}/${results.a.length}`); console.log(`Non-null: ${aNonNull}/${results.a.length}`); if (aNonNull > 0) { console.log("\nInvented clarification targets:"); results .filter((r) => !r.isNull) .forEach((r) => console.log(` - "${r.result.clarificationTarget}"`)); } console.log(`\n--- Case B (true → target) ---`); for (const r of results.b) { console.log( `Run ${r.run}: ${r.classification} | "${r.result ?? "null"}"` ); } console.log(`Correct: ${bCorrect}/${results.b.length}`); console.log(`Incorrect: ${bIncorrect}/${results.b.length}`); console.log(`Null: ${bNull}/${results.b.length}`); console.log(`\n--- Timing ---`); console.log(`Total live calls: ${timings.length}`); console.log(`Total time: ${totalMs}ms (${(totalMs / 1000).toFixed(1)}s)`); console.log(`Average: ${avgMs.toFixed(1)}ms per call`); console.log(`Fastest: ${fastMs}ms`); console.log(`Slowest: ${slowMs}ms`); // Summary assertions expect(timings.length).toBe(6); expect(aNull + aNonNull).toBe(3); expect(bCorrect + bIncorrect + bNull).toBe(3); }); });