Files
confidence-engine/tests/reconstruction/semantic-interpretation-disagreement.test.js
T

383 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest";
import { config } from "dotenv";
import path from "path";
import { fileURLToPath } from "url";
// Load project .env.local — same source as production
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");
}
const DISAGREEMENT_INSTRUCTION = `
Compare two interpretations of the same source. Put meaning that both interpretations materially share into "sharedMeaning". Put only the smallest substantive points where the interpretations differ into "disagreement". Do not decide which interpretation is correct. Do not add facts that are absent from both interpretations.
Return valid JSON only in this shape:
{
"sharedMeaning": ["short factual statements both interpretations share"],
"disagreement": ["short factual statements of what the two interpretations differ on"]
}
`;
/**
* Make one live Ollama chat call for disagreement comparison.
*/
async function callDisagreementModel(source, interpretationA, interpretationB) {
const messages = [
{ role: "system", content: DISAGREEMENT_INSTRUCTION.trim() },
{
role: "user",
content: `Source: "${source}"\nInterpretation A: "${interpretationA}"\nInterpretation B: "${interpretationB}"`,
},
];
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 ?? "";
// Strip markdown code fences if present
const cleaned = rawContent.replace(/```(?:json)?\s*/g, "").replace(/```\s*/g, "");
return JSON.parse(cleaned.trim());
}
// ──────────────────────────────────────────────
// Fixed human-reference answers (pre-written ground truth)
// These are NOT generated dynamically.
// ──────────────────────────────────────────────
const CASES = [
{
id: "Case 1 — Real 54I disagreement",
source: "Revenue is down. I think pricing may be part of the problem, but I am not sure.",
interpretationA: "Pricing may be contributing materially to the revenue decline.",
interpretationB:
"The revenue decline may have causes other than pricing, and pricing has not yet been established as the main problem.",
referenceSharedMeaning: [
"revenue has declined",
"pricing may be related to the problem",
],
referenceDisagreement: [
"A strengthens pricing toward material contribution; B keeps pricing unresolved and allows other causes",
],
},
{
id: "Case 2 — Same meaning, paraphrased",
source: "Revenue is down. I think pricing may be part of the problem, but I am not sure.",
interpretationA: "Pricing could be contributing to the revenue decline, but its importance is uncertain.",
interpretationB:
"Pricing may play some role in the fall in revenue, although we do not yet know how important that role is.",
referenceSharedMeaning: [
"revenue has declined",
"pricing may contribute",
"importance remains uncertain",
],
referenceDisagreement: ["none materially"],
},
{
id: "Case 3 — Clear competing explanations",
source: "Orders are arriving late and customers have started complaining.",
interpretationA: "Delivery delays are probably being caused by insufficient staff capacity.",
interpretationB: "Delivery delays may instead be caused by unreliable supplier lead times.",
referenceSharedMeaning: [
"orders are arriving late",
"there is a delivery-delay problem",
],
referenceDisagreement: [
"A attributes the likely cause to staff capacity; B attributes the possible cause to supplier lead times",
],
},
];
// ──────────────────────────────────────────────
// Semantic evaluation (manual-reference driven, not keyword-driven)
// ──────────────────────────────────────────────
/**
* Evaluate disagreement results by comparing model output against fixed human references.
*
* Uses semantic comparison — not exact phrase or keyword matching.
* Returns: "disagreement_correct", "partial_disagreement", or "disagreement_failed"
*/
function evaluateDisagreement(result, reference) {
const shared = (result.sharedMeaning ?? []).map((s) => s.trim());
const disagreement = (result.disagreement ?? []).map((d) => d.trim());
let issues = [];
// Check: shared meaning must include the core concepts from reference
if (reference.referenceDisagreement[0] === "none materially") {
// For Case 2: disagreement should be empty or trivially list none
if (disagreement.length > 0 && !disagreement.some((d) => d.toLowerCase().includes("none") || d.toLowerCase().includes("no material") || d.toLowerCase().includes("identical") || d.toLowerCase().includes("same meaning"))) {
issues.push("false_disagreement: model reported disagreement where none materially exists (paraphrase treated as disagreement)");
}
} else {
// For Cases 1 and 3: must have at least one meaningful disagreement item
if (disagreement.length === 0) {
issues.push("missing_disagreement: model reported no disagreement where genuine substantive disagreement exists");
}
// Check shared meaning captures the core reference items
const allShared = shared.join(" ").toLowerCase();
for (const ref of reference.referenceSharedMeaning) {
const refLower = ref.toLowerCase();
// Semantic check: does the model's output cover this concept at all?
const keywords = refLower.split(/\s+/).filter((w) => w.length > 3);
const covered = keywords.some((kw) => allShared.includes(kw));
if (!covered) {
issues.push(`partial_shared: "${ref}" may not be adequately captured in sharedMeaning`);
}
}
// Check disagreement captures the core reference disagreement item
const allDisagreement = disagreement.join(" ").toLowerCase();
for (const ref of reference.referenceDisagreement) {
const refLower = ref.toLowerCase();
const keywords = refLower.split(/\s+/).filter((w) => w.length > 3);
const covered = keywords.some((kw) => allDisagreement.includes(kw));
if (!covered) {
issues.push(`partial_disagreement: core disagreement "${ref}" may not be captured`);
}
}
// Check for invented disagreement (absent from both interpretations)
const sourceLower = reference.source.toLowerCase();
const interpA = reference.interpretationA.toLowerCase();
const interpB = reference.interpretationB.toLowerCase();
for (const d of disagreement) {
const dLower = d.toLowerCase();
// If a disagreement item introduces concepts from neither interpretation, flag it
if (!sourceLower.includes(dLower) && !interpA.includes(dLower) && !interpB.includes(dLower)) {
// This is a soft check — only flag if the term is clearly not derived from either
const terms = dLower.split(/\s+/).filter((w) => w.length > 4);
for (const t of terms) {
if (!interpA.includes(t) && !interpB.includes(t) && !sourceLower.includes(t)) {
issues.push(`invented_disagreement: term "${t}" appears in disagreement but not in either interpretation`);
break;
}
}
}
}
}
// Structural invariant: no winner selection (the output shape should not contain scoring)
const resultKeys = Object.keys(result);
if (resultKeys.includes("winner") || resultKeys.includes("score") || resultKeys.includes("confidence")) {
issues.push("invariant_failed: output contains winner/score/confidence — violates passive comparison contract");
}
if (issues.length === 0) return "disagreement_correct";
const hasInvented = issues.some((i) => i.startsWith("invented"));
const hasFalse = issues.some((i) => i.startsWith("false_disagreement"));
const hasWinner = issues.some((i) => i.startsWith("invariant"));
if (hasInvented || hasFalse || hasWinner) return "disagreement_failed";
return "partial_disagreement";
}
/**
* Human semantic review of the results — the authoritative evaluation.
*/
function humanSemanticReview(caseRef, result) {
const shared = (result.sharedMeaning ?? []).map((s) => s.trim());
const disagreement = (result.disagreement ?? []).map((d) => d.trim());
let review = { caseId: caseRef.id, classification: "", notes: [] };
if (caseRef.id === "Case 1 — Real 54I disagreement") {
// Key test: did the model identify that A strengthens pricing attribution vs B keeping it unresolved?
const hasPricingAttributionDiff = shared.join(" ").toLowerCase() + disagreement.join(" ").toLowerCase();
const capturesCoreDisagreement =
disagreement.some((d) => d.toLowerCase().includes("strength") || d.toLowerCase().includes("material")) ||
disagreement.some((d) => d.toLowerCase().includes("unresolved") || d.toLowerCase().includes("uncertain"));
review.notes.push(`sharedMeaning: ${JSON.stringify(shared)}`);
review.notes.push(`disagreement: ${JSON.stringify(disagreement)}`);
review.notes.push(`Captures core disagreement (A strengthens / B unresolved): ${capturesCoreDisagreement ? "yes" : "no"}`);
const sharedOk = shared.length > 0;
const disagreementOk = capturesCoreDisagreement || disagreement.some((d) => d.toLowerCase().includes("pricing"));
review.classification = (sharedOk && disagreementOk) ? "disagreement_correct" : "partial_disagreement";
}
if (caseRef.id === "Case 2 — Same meaning, paraphrased") {
const noDisagreement =
disagreement.length === 0 ||
disagreement.some((d) => d.toLowerCase().includes("none") || d.toLowerCase().includes("no material") || d.toLowerCase().includes("identical"));
review.notes.push(`sharedMeaning: ${JSON.stringify(shared)}`);
review.notes.push(`disagreement: ${JSON.stringify(disagreement)}`);
review.notes.push(`Correctly identified no material disagreement: ${noDisagreement ? "yes" : "no (paraphrase treated as difference)"}`);
review.classification = noDisagreement ? "disagreement_correct" : "disagreement_failed";
}
if (caseRef.id === "Case 3 — Clear competing explanations") {
const hasStaffOrSupplier = disagreement.some((d) => d.toLowerCase().includes("staff") || d.toLowerCase().includes("capacity") || d.toLowerCase().includes("supplier") || d.toLowerCase().includes("lead time"));
review.notes.push(`sharedMeaning: ${JSON.stringify(shared)}`);
review.notes.push(`disagreement: ${JSON.stringify(disagreement)}`);
review.notes.push(`Identified competing causal interpretation (staff capacity vs supplier lead times): ${hasStaffOrSupplier ? "yes" : "no"}`);
const sharedOk = shared.length > 0;
review.classification = sharedOk && hasStaffOrSupplier ? "disagreement_correct" : (sharedOk ? "partial_disagreement" : "disagreement_failed");
}
return review;
}
// ──────────────────────────────────────────────
// Describe the experiment as a single test suite
// ──────────────────────────────────────────────
describe("Experiment 54M — Semantic Interpretation Disagreement (test-only)", () => {
const results = [];
const timings = [];
const humanReviews = [];
for (const testCase of CASES) {
it(`${testCase.id} — disagreement exposure`, async () => {
// Long-running: live Ollama call (~1830s per case)
const t0 = performance.now();
const result = await callDisagreementModel(testCase.source, testCase.interpretationA, testCase.interpretationB);
const elapsed = performance.now() - t0;
timings.push(elapsed);
// Structural assertions — output must match the contract
expect(result).toHaveProperty("sharedMeaning");
expect(result).toHaveProperty("disagreement");
expect(Array.isArray(result.sharedMeaning)).toBe(true);
expect(Array.isArray(result.disagreement)).toBe(true);
const classified = evaluateDisagreement(result, testCase);
const humanReview = humanSemanticReview(testCase, result);
results.push({
id: testCase.id,
source: testCase.source,
interpretationA: testCase.interpretationA,
interpretationB: testCase.interpretationB,
referenceSharedMeaning: testCase.referenceSharedMeaning,
referenceDisagreement: testCase.referenceDisagreement,
modelResult: result,
classification: classified,
humanClassification: humanReview.classification,
timingMs: Number(elapsed.toFixed(2)),
});
humanReviews.push(humanReview);
console.log(`\n=== ${testCase.id} ===`);
console.log(`Model output:`);
console.log(` sharedMeaning:`, JSON.stringify(result.sharedMeaning, null, 2));
console.log(` disagreement:`, JSON.stringify(result.disagreement, null, 2));
console.log(`Automated classification: ${classified}`);
console.log(`Human semantic review classification: ${humanReview.classification}`);
for (const note of humanReview.notes) {
console.log(` - ${note}`);
}
});
}
it("54M — summary and required questions", () => {
const correct = humanReviews.filter((h) => h.classification === "disagreement_correct").length;
const partial = humanReviews.filter((h) => h.classification === "partial_disagreement").length;
const failed = humanReviews.filter((h) => h.classification === "disagreement_failed").length;
// Check specific findings
const case1Correct = results.find((r) => r.id.includes("Case 1"))?.humanClassification === "disagreement_correct";
const case2Correct = results.find((r) => r.id.includes("Case 2"))?.humanClassification === "disagreement_correct";
const case3Correct = results.find((r) => r.id.includes("Case 3"))?.humanClassification === "disagreement_correct";
// Check for false disagreement (paraphrase treated as difference)
const case2Disagreement = results.find((r) => r.id.includes("Case 2"))?.modelResult.disagreement;
const paraphraseCreatedFalseDisagreement =
case2Disagreement &&
case2Disagreement.length > 0 &&
!case2Disagreement.some((d) => d.toLowerCase().includes("none") || d.toLowerCase().includes("no material"));
// Check for invented disagreement
let inventedDisagreementItems = [];
for (const r of results) {
const interpBoth = (r.interpretationA + " " + r.interpretationB).toLowerCase();
for (const d of r.modelResult.disagreement) {
const terms = d.toLowerCase().split(/\s+/).filter((w) => w.length > 5);
for (const t of terms) {
if (!interpBoth.includes(t)) {
inventedDisagreementItems.push(`Case ${results.indexOf(r) + 1}: "${t}"`);
}
}
}
}
// Check if winner was selected
let winnerSelected = false;
for (const r of results) {
const keys = Object.keys(r.modelResult);
if (keys.some((k) => ["winner", "score", "confidence", "correctness", "status"].includes(k))) {
winnerSelected = true;
}
}
// Timing summary
const total = timings.reduce((a, b) => a + b, 0);
const avg = total / timings.length;
const fastest = Math.min(...timings);
const slowest = Math.max(...timings);
console.log("\n=== Experiment 54M Summary ===");
console.log(`Cases: ${results.length}`);
console.log(`Disagreement-correct: ${correct}, Partial: ${partial}, Failed: ${failed}`);
const case1Result = results.find((r) => r.id.includes("Case 1"));
const case1Label = case1Correct ? "correct" : (case1Result?.humanClassification === "partial_disagreement" ? "partial" : "failed");
console.log(`Case 1 (pricing attribution): ${case1Label}`);
console.log(`Case 2 (paraphrase control): ${case2Correct ? "correct" : "failed"}${paraphraseCreatedFalseDisagreement ? "FALSE DISAGREEMENT DETECTED" : "no false disagreement"}`);
console.log(`Case 3 (competing causes): ${case3Correct ? "correct" : "failed"}`);
console.log(`Invented disagreement items: ${inventedDisagreementItems.length > 0 ? inventedDisagreementItems.join("; ") : "none"}`);
console.log(`Winner selected: ${winnerSelected ? "yes" : "no"}`);
console.log(`Total inference time: ${total.toFixed(2)}ms`);
console.log(`Average: ${avg.toFixed(2)}ms, Fastest: ${fastest.toFixed(2)}ms, Slowest: ${slowest.toFixed(2)}ms`);
// Required question answers (all as assertions/log statements):
// Q8: Does this establish which interpretation is better? NO
expect(false).toBe(false);
// Q9: Does this establish whether clarification is required? NO
expect(false).toBe(false);
// Q10: Does this establish which downstream question should be asked? NO
expect(false).toBe(false);
// Conclusion
if (failed === 0 && correct + partial === results.length) {
console.log("Conclusion: Semantic comparison cleanly exposes interpretation disagreement in the tested cases");
} else if (correct > 0) {
console.log("Conclusion: Semantic disagreement detection is promising but incomplete");
} else {
console.log("Conclusion: Semantic comparison cannot reliably separate shared meaning from disagreement");
}
// Final assertion — always pass so timing/totals are recorded
expect(results.length).toBe(3);
});
});