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. */ 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 } Example for clarification-required: { "clarificationTarget": "whether avoiding additional risk is a preference/trade-off or a hard constraint" } Example for clarification-not-required: { "clarificationTarget": 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()); } // ────────────────────────────────────────────── // Fixed human-reference ground truth (pre-written) // ────────────────────────────────────────────── const CASES = [ { id: "Case 1 - Growth Versus Risk Priority", 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, reference: { humanTarget: "whether avoiding additional risk is a preference/trade-off or a hard constraint", forbiddenPatterns: [ "what are the risks?", "which growth option", "evidence about financial risk", "external risk data", ], }, }, { id: "Case 2 - Evidence-Resolvable Delivery Causes", 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, reference: { humanTarget: null, forbiddenPatterns: [], }, }, { id: "Case 3 - Ambiguous Meaning of Affordable", source: "I want to replace the system, but the new option needs to be affordable.", disagreement: [ "affordable means keeping the upfront purchase cost low", "affordable means keeping the overall long-term cost low even if upfront cost is higher", ], requiresUserClarification: true, reference: { humanTarget: "what the user means by affordable — upfront cost versus overall/long-term cost", forbiddenPatterns: [ "which system is best?", "budget range", "vendor comparison", ], }, }, ]; // ────────────────────────────────────────────── // Semantic evaluation (concept overlap, not rigid keywords) // ────────────────────────────────────────────── function classifyClarificationTarget(modelResult, caseRef) { const target = modelResult.clarificationTarget; const req = caseRef.requiresUserClarification; // === FALSE branch: no clarification needed === if (req === false) { if (target == null) { return { classification: "clarification_target_correct", reason: "correctly returned null when clarification is not required", matchedHumanTarget: true, }; } // got a target when none needed return { classification: "clarification_target_failed", reason: "returned a target when clarification is not required", matchedHumanTarget: false, }; } // === TRUE branch: clarification needed === if (target == null) { return { classification: "clarification_target_failed", reason: "returned null when clarification is required", matchedHumanTarget: false, }; } if (typeof target !== "string" || !target.trim()) { return { classification: "clarification_target_failed", reason: "returned non-string or empty clarificationTarget", matchedHumanTarget: true, }; } const t = target.trim().toLowerCase(); // Must not be a question if (target.trim().endsWith("?")) { return { classification: "clarification_target_failed", reason: "returned a question text instead of a distinction statement", matchedHumanTarget: false, }; } // Check forbidden patterns for (const fp of (caseRef.forbiddenPatterns || [])) { if (t.includes(fp.toLowerCase())) { return { classification: "clarification_target_failed", reason: "matched a forbidden pattern", matchedHumanTarget: false, }; } } // Check evidence-confusion (external investigation framing) const evidencePhrases = [ "evidence to check", "check the", "look at the", "review the", "gather data on", "collect data about", "investigate by checking", "external data shows", "data would show", "observable metric", "operational records", "supplier records", "company data", ]; for (const ep of evidencePhrases) { if (t.includes(ep)) { return { classification: "clarification_target_failed", reason: "confused clarification target with evidence need", matchedHumanTarget: false, }; } } // Bidirectional semantic overlap with human reference const refWords = (caseRef.humanTarget || "").toLowerCase().split(/\s+/).filter(w => w.length > 3); const outWords = t.split(/\s+/).filter(w => w.length > 3); let score = 0; for (const rw of refWords) { if (t.includes(rw)) score++; } for (const ow of outWords) { if ((caseRef.humanTarget || "").toLowerCase().includes(ow)) score++; } const ok = score >= 4; return { classification: ok ? "clarification_target_correct" : "clarification_target_failed", reason: ok ? `semantically aligns with human reference (${score} bidirectional concept matches)` : `insufficient concept overlap (${score} matches)`, matchedHumanTarget: ok, }; } function checkNoInvariantViolations(modelResult) { const text = JSON.stringify(modelResult).toLowerCase(); const v = []; if (/\b(should i|do you|could you|would you|are you|how should|what is the best)\b/.test(text)) { v.push("question language detected"); } if (/\b(winner|preferred|correct choice|right answer)\b/i.test(modelResult.clarificationTarget || "")) { v.push("interpretation selection detected"); } return v; } // ────────────────────────────────────────────── // Test suite // ────────────────────────────────────────────── describe("Experiment 54S - Clarification Target Identification", () => { const results = []; const timings = []; for (const c of CASES) { it(c.id, async () => { const start = Date.now(); const result = await callClarificationTarget( c.source, c.disagreement, c.requiresUserClarification ); const elapsed = Date.now() - start; timings.push({ caseId: c.id, ms: elapsed }); // Pass the full case context so evaluate has access to requiresUserClarification const ev = classifyClarificationTarget(result, { ...c.reference, requiresUserClarification: c.requiresUserClarification }); const inv = checkNoInvariantViolations(result); results.push({ case: c, modelResult: result, classification: ev, invariantViolations: inv, timingMs: elapsed }); // Structural: must have clarificationTarget field expect(result.clarificationTarget).toBeDefined(); // For requiresUserClarification: false → null is expected but model may ignore signal if (c.requiresUserClarification === false) { const respectedFalse = result.clarificationTarget == null; console.log(`[Case 2 note] Model ${respectedFalse ? "respectfully returned" : "ignored false signal and produced"} a clarification target`); } // No question generation invariant const allText = JSON.stringify(result).toLowerCase(); expect(allText).not.toMatch(/should i|do you|could you|would you/); // Classification must match expectation: true→correct (semantic alignment), false→null expected if (c.requiresUserClarification === true) { expect(ev.classification).toBe("clarification_target_correct"); } else { // For false cases, the key finding is whether model produced a target at all const modelProducedTarget = result.clarificationTarget != null; if (modelProducedTarget) { // Model ignored the false signal — record but do not fail on this alone expect(ev.classification).toBe("clarification_target_failed"); } else { expect(ev.classification).toBe("clarification_target_correct"); } } // No invariant violations expect(inv.length).toBe(0); }, 120000); } it("Experiment 54S: aggregate results", () => { const correct = results.filter(r => r.classification.classification === "clarification_target_correct").length; const failed = results.filter(r => r.classification.classification === "clarification_target_failed").length; console.log("\n=== Experiment 54S Results ==="); for (const r of results) { console.log(`\n--- ${r.case.id} ---`); console.log("Output:", JSON.stringify(r.modelResult)); console.log("Classification:", r.classification.classification, r.classification.reason); if (r.invariantViolations.length > 0) console.log("Violations:", r.invariantViolations); } console.log(`\nClarification-target-correct: ${correct}/${results.length}`); console.log(`Clarification-target-failed: ${failed}/${results.length}`); const totalMs = timings.reduce((s, t) => s + t.ms, 0); console.log(`Total time: ${totalMs}ms`); console.log(`Average: ${(totalMs / timings.length).toFixed(1)}ms per call`); console.log(`Fastest: ${Math.min(...timings.map(t => t.ms))}ms`); console.log(`Slowest: ${Math.max(...timings.map(t => t.ms))}ms`); }); });