#!/usr/bin/env node /** * Evaluation harness for Confidence Engine v0.2 — semantic reasoning evaluator. * * Measures reasoning behaviour rather than exact wording. Uses: * • Multiple accepted classifications per case (classification tolerance) * • Behaviour expectations with accepted signals (not literal phrases) * • Proper separation of schema failure from reasoning evaluation * • Evidence-type alias normalisation with diagnostics logging * • Structured output field inspection alongside text matching * * Scoring categories: * TECHNICAL — structural correctness of the output: * • Schema validity (does the JSON match the schema?) * • Classification accuracy (primary type among accepted types? reasoning modes present?) * • Next-question presence (is a nextQuestion emitted?) * * REASONING QUALITY — faithfulness of the inference: * • Required concept presence (legacy field, kept for backward compat) * • Unsupported inference absence (prohibited claims genuinely absent?) * • Expected behaviour coverage (semantic matching across multiple signal types) * * A test case can pass technical but fail reasoning (hallucination), * or pass reasoning but fail technical (missing fields, schema errors). * If schema fails: reasoning is marked 'not_evaluated' — no vacuous truth. */ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // ── Config modes ───────────────────────────────────── const useRealProvider = process.env.EVAL_REAL === "1"; const useDiagnostic = process.env.EVAL_DIAGNOSTIC === "1"; const savedResultsDir = process.env.EVAL_SAVED_RESULTS; let mode = "normal"; // normal | diagnostic | saved if (savedResultsDir) { mode = "saved"; } else if (useDiagnostic) { mode = "diagnostic"; } let testDataPath; if (mode === "diagnostic") { testDataPath = join(__dirname, "data", "live-diagnostic-v0.2.json"); } else if (mode === "saved") { testDataPath = null; } else { testDataPath = join(__dirname, "test-data", "v0.2-evaluation.jsonl"); } const resultsDir = mode === "diagnostic" ? join(__dirname, "..", "evaluation-results") : mode === "saved" ? savedResultsDir : join(__dirname, "..", "tests-results"); if (!existsSync(resultsDir)) mkdirSync(resultsDir, { recursive: true }); // ═══════════════════════════════════════════════════════ // SYNONYM / SIGNAL GROUPS FOR SEMANTIC MATCHING // ═══════════════════════════════════════════════════════ const SYN_G = { baseline: [ "baseline", "previous period", "last month", "before", "normal level", "comparison period", "prior state", "previous state", "original", "historical", "pre-", "formerly", "initially", ], subset: [ "some", "subset", "partial", "certain users", "not universal", "limited to", "only a few", "a number of", "several", ], metricNorm: [ "normalise", "denominator", "rate", "comparable scale", "per unit", "absolute vs relative", "per customer", "per transaction", "basis points", ], contra: [ "contradiction", "divergent", "opposing", "conflicting", "contrary to", "but", "however", "yet", "in contrast", "despite", "conversely", ], trans: [ "transition", "change from", "before to", "moved from", "shifted", "after", "since", "following", "subsequent to", "temporal sequence", ], claimVal: [ "validate", "corroborate", "verify", "confirm", "evidence needed", "single report", "one user", "anecdotal", "unverified", "claim", ], }; // ═══════════════════════════════════════════════════════ // NORMALISE + MATCHERS (deterministic, inspectable) // ═══════════════════════════════════════════════════════ function normalise(text) { return String(text) .toLowerCase() .replace(/[^\w\s_]/g, " ") .replace(/\s+/g, " ") .trim(); } function matchesAnyPhrase(text, signals) { if (!signals?.length || !text) return false; const norm = normalise(text); return signals.some((s) => norm.includes(normalise(s))); } function matchesReasoningMode(actualModes, acceptedModes) { if (!acceptedModes?.length) return false; const actual = (actualModes || []).map((m) => String(m).toLowerCase().replace(/\s+/g, "_"), ); const normA = acceptedModes.map((m) => String(m).toLowerCase().replace(/\s+/g, "_"), ); return normA.some((a) => actual.includes(a)); } function matchesClassification(actualPrimary, acceptedTypes) { if (!acceptedTypes?.length || !actualPrimary) return false; const a = String(actualPrimary).toLowerCase().replace(/\s+/g, "_"); const n = acceptedTypes.map((t) => String(t).toLowerCase().replace(/\s+/g, "_"), ); return n.includes(a); } function matchesSecondaryClassification(actualSecondary, acceptedTypes) { if (!acceptedTypes?.length || !actualSecondary?.length) return false; const actual = actualSecondary.map((t) => String(t).toLowerCase().replace(/\s+/g, "_"), ); const normA = acceptedTypes.map((t) => String(t).toLowerCase().replace(/\s+/g, "_"), ); return normA.some((a) => actual.includes(a)); } function matchesStructuredField(output, fieldPath, signals) { if (!output || !fieldPath?.length || !signals?.length) return false; const parts = fieldPath.split("."); let value = output; for (const p of parts) { if (value == null) return false; value = value[p]; } if (!Array.isArray(value)) { if (typeof value === "object") { const descs = []; for (const k of ["description", "summary", "reason"]) { if (typeof value[k] === "string") descs.push(value[k]); } return matchesAnyPhrase(descs.join(" "), signals); } return false; } const all = []; for (const item of value) { if (typeof item === "object" && item !== null) { for (const k of ["description", "summary", "reason"]) { if (typeof item[k] === "string") all.push(item[k]); } } else if (typeof item === "string") { all.push(item); } } return matchesAnyPhrase(all.join(" "), signals); } function checksImportantUnknowns(output, acceptedSignals) { const unknowns = output?.importantUnknowns || []; if (!unknowns.length) return false; return matchesAnyPhrase( unknowns.map((u) => u.description || "").join(" "), acceptedSignals, ); } function checksNextQuestionTarget(output, acceptedSignals) { if (!output?.nextQuestion) return false; const q = output.nextQuestion; const textParts = [q.question, q.reason].filter(Boolean).join(" "); const targetText = (q.targets || []).map(String).join(" "); return matchesAnyPhrase(`${textParts} ${targetText}`, acceptedSignals); } // ═══════════════════════════════════════════════════════ // EVIDENCE NORMALISATION + VALIDATION // ═══════════════════════════════════════════════════════ const VALID_EVIDENCE_TYPES = [ "direct_observation", "reported_statement", "interpretation", "assumption", "inferred_relationship", ]; const EVIDENCE_ALIASES = { reported_claim: "reported_statement" }; function normaliseEvidence(evidenceArray, diagnostics) { const result = []; let nullRemoved = 0; let aliasChanged = 0; let invalidTypes = []; if (!Array.isArray(evidenceArray)) return { data: [], nullRemoved: 0, aliasChanged: 0, invalidTypes: [] }; for (const entry of evidenceArray) { // Remove null entries with logging if (entry === null || entry === undefined) { nullRemoved++; continue; } // Normalise evidence type aliases let e = { ...entry }; if (e.evidenceType && EVIDENCE_ALIASES[e.evidenceType]) { const original = e.evidenceType; e.evidenceType = EVIDENCE_ALIASES[e.evidenceType]; aliasChanged++; invalidTypes.push({ removed: null, normalised: null }); // placeholder } // Reject invalid evidence types with clear diagnostic info if (e.evidenceType && !VALID_EVIDENCE_TYPES.includes(e.evidenceType)) { diagnostics.push({ action: "invalid_evidence_type", originalEvidenceType: e.evidenceType, validTypes: VALID_EVIDENCE_TYPES, evidenceIndex: result.length + nullRemoved, }); // Still include the entry but log the warning — don't reject entirely } result.push(e); } return { data: result, nullRemoved, aliasChanged, invalidTypes }; } // ═══════════════════════════════════════════════════════ // BEHAVIOUR MATCHING (per behaviour type) // ═══════════════════════════════════════════════════════ function evaluateBehaviour(behaviour, output, analysisResult) { const result = { id: behaviour.id, description: behaviour.description, type: behaviour.type, pass: false, signalsChecked: behaviour.acceptedSignals || [], matchedSignals: [], }; const primary = analysisResult?.inputClassification?.primaryType; const secondary = analysisResult?.inputClassification?.secondaryTypes || []; const modes = analysisResult?.inputClassification?.reasoningModes || []; const evidence = output?.evidence || []; const summary = output?.reconstruction?.summary || ""; const evidenceTexts = evidence.map((e) => e.description || "").join(" "); const allOutput = [ summary, evidenceTexts, analysisResult?.nextQuestion?.question || "", analysisResult?.nextQuestion?.reason || "", ] .filter(Boolean) .join(" "); switch (behaviour.type) { case "classification": { const classPass = matchesClassification( primary, behaviour.acceptedSignals, ); const secPass = matchesSecondaryClassification( secondary, behaviour.acceptedSignals, ); result.pass = classPass || secPass; result.matchedSignals = classPass ? [primary] : secPass ? secondary.filter((s) => matchesClassification(s, behaviour.acceptedSignals), ) : []; break; } case "reasoning_mode": { result.pass = matchesReasoningMode(modes, behaviour.acceptedSignals); result.matchedSignals = result.pass ? modes.filter((m) => behaviour.acceptedSignals?.some( (a) => normalise(a) === normalise(m), ), ) || [] : []; break; } case "observation_recognition": { result.signalsChecked = behaviour.acceptedSignals; const fp = matchesStructuredField( output, "reconstruction.differences", behaviour.acceptedSignals, ); const sp = matchesAnyPhrase(summary, behaviour.acceptedSignals); const ep = matchesAnyPhrase(evidenceTexts, behaviour.acceptedSignals); result.pass = fp || sp || ep; result.matchedSignals = [ ...(fp ? ["structured field"] : []), ...(sp ? ["summary"] : []), ...(ep ? ["evidence"] : []), ]; break; } case "subset_recognition": { result.signalsChecked = behaviour.acceptedSignals; const fp = matchesStructuredField( output, "reconstruction.differences", behaviour.acceptedSignals, ); const sp = matchesAnyPhrase(summary, behaviour.acceptedSignals); const ukp = checksImportantUnknowns(output, [ ...SYN_G.subset.slice(0, 3), ...behaviour.acceptedSignals, ]); result.pass = fp || sp || ukp; result.matchedSignals = [ ...(fp ? ["reconstruction differences"] : []), ...(sp ? ["summary text"] : []), ...(ukp ? ["important unknowns"] : []), ]; break; } case "baseline_recognition": { result.signalsChecked = behaviour.acceptedSignals; const mp = matchesReasoningMode(modes, SYN_G.baseline.slice(0, 3)); const ukp = checksImportantUnknowns(output, [ ...SYN_G.baseline, ...behaviour.acceptedSignals, ]); const sp = matchesAnyPhrase(summary, [ ...SYN_G.baseline, ...behaviour.acceptedSignals, ]); result.pass = mp || ukp || sp; result.matchedSignals = [ ...(mp ? ["reasoning mode"] : []), ...(ukp ? ["important unknowns"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "metric_relationship": { result.signalsChecked = behaviour.acceptedSignals; const fp = matchesStructuredField(output, "reconstruction.differences", [ ...SYN_G.metricNorm, ...behaviour.acceptedSignals, ]); const sp = matchesAnyPhrase(summary, [ ...SYN_G.metricNorm, ...behaviour.acceptedSignals, ]); result.pass = fp || sp; result.matchedSignals = [ ...(fp ? ["differences field"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "contradiction_recognition": { result.signalsChecked = behaviour.acceptedSignals; const fp = matchesStructuredField(output, "reconstruction.differences", [ ...SYN_G.contra, ...behaviour.acceptedSignals, ]); const cp = output?.reconstruction?.contradictions?.some((c) => matchesAnyPhrase(c.description || "", SYN_G.contra), ); const sp = matchesAnyPhrase(summary, [ ...SYN_G.contra, ...behaviour.acceptedSignals, ]); result.pass = fp || cp || sp; result.matchedSignals = [ ...(fp ? ["differences field"] : []), ...(cp ? ["contradictions field"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "transition_recognition": { result.signalsChecked = behaviour.acceptedSignals; const mp = matchesReasoningMode(modes, SYN_G.trans.slice(0, 2)); const tp = checksImportantUnknowns(output, [ ...SYN_G.trans, ...behaviour.acceptedSignals, ]); const sp = matchesAnyPhrase(summary, [ ...SYN_G.trans, ...behaviour.acceptedSignals, ]); result.pass = mp || tp || sp; result.matchedSignals = [ ...(mp ? ["reasoning mode"] : []), ...(tp ? ["important unknowns"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "claim_validation": { result.signalsChecked = behaviour.acceptedSignals; const mp = matchesReasoningMode(modes, SYN_G.claimVal.slice(0, 2)); const sp = matchesAnyPhrase(summary, [ ...SYN_G.claimVal, ...behaviour.acceptedSignals, ]); result.pass = mp || sp; result.matchedSignals = [ ...(mp ? ["reasoning mode"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "unsupported_justification": { // Check that prohibited signals are absent from ALL output text result.signalsChecked = behaviour.prohibitedSignals || []; const allLower = [summary, evidenceTexts].join(" ").toLowerCase(); result.pass = !(behaviour.prohibitedSignals || []).some((p) => allLower.includes(normalise(p)), ); result.matchedSignals = result.pass ? ["all prohibited signals absent"] : []; break; } case "measurement_normalisation": { result.signalsChecked = behaviour.acceptedSignals; const fp = matchesStructuredField(output, "reconstruction.differences", [ ...SYN_G.metricNorm, ...behaviour.acceptedSignals, ]); const sp = matchesAnyPhrase(summary, [ ...SYN_G.metricNorm, ...behaviour.acceptedSignals, ]); result.pass = fp || sp; result.matchedSignals = [ ...(fp ? ["differences field"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "timing_recognition": { result.signalsChecked = behaviour.acceptedSignals; const sp = matchesAnyPhrase(summary, behaviour.acceptedSignals); const ukp = checksImportantUnknowns(output, behaviour.acceptedSignals); result.pass = sp || ukp; result.matchedSignals = [ ...(sp ? ["summary text"] : []), ...(ukp ? ["important unknowns"] : []), ]; break; } case "ambiguity_recognition": { result.signalsChecked = behaviour.acceptedSignals; const mp = matchesReasoningMode(modes, SYN_G.claimVal.slice(0, 1)); const clPass = matchesClassification(primary, ["ambiguous_statement"]); const sp = matchesAnyPhrase(summary, [ ...SYN_G.claimVal.slice(0, 2), ...behaviour.acceptedSignals, ]); result.pass = clPass || mp || sp; result.matchedSignals = [ ...(clPass ? ["classification: ambiguous_statement"] : []), ...(mp ? ["reasoning mode"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "proposed_action_recognition": { result.signalsChecked = behaviour.acceptedSignals; const clPass = matchesClassification(primary, [ "decision_request", "desired_outcome", ]); const sp = matchesAnyPhrase(summary, behaviour.acceptedSignals || []); result.pass = clPass || sp; result.matchedSignals = [ ...(clPass ? ["classification match"] : []), ...(sp ? ["summary text"] : []), ]; break; } case "missing_information_recognition": { result.signalsChecked = behaviour.acceptedSignals; const mp = matchesReasoningMode(modes, [ "identify_missing_information", "decision_support", ]); const ukCount = (output?.importantUnknowns || []).length; result.pass = mp || ukCount > 0; result.matchedSignals = [ ...(mp ? ["identify_missing_information mode"] : []), ...(ukCount > 0 ? `${ukCount} important unknowns identified` : []), ]; break; } case "next_question_target": { result.pass = checksNextQuestionTarget( output, behaviour.acceptedSignals || [], ); result.signalsChecked = behaviour.acceptedSignals || []; result.matchedSignals = result.pass ? ["next question text", "next question reason"] : []; break; } default: { result.signalsChecked = behaviour.acceptedSignals || []; result.pass = matchesAnyPhrase( allOutput, behaviour.acceptedSignals || [], ); result.matchedSignals = result.pass ? ["text match"] : []; } } return result; } function calculateBehaviourCoverage(behaviours, results) { if (!behaviours?.length) return { coverage: "n/a", details: [], requiredPass: true }; const required = behaviours.filter((b) => b.required !== false); const optional = behaviours.filter((b) => b.required === false); const allResults = results || behaviours.map((b) => evaluateBehaviour(b, {}, {})); let passCount = 0; let totalChecked = 0; let requiredFailCount = 0; const details = []; for (const behaviour of behaviours) { const matchResult = allResults.find((r) => r.id === behaviour.id); const matched = matchResult || evaluateBehaviour(behaviour, {}, {}); const isRequired = behaviour.required !== false; passCount += matched.pass ? 1 : 0; totalChecked += 1; if (!matched.pass && isRequired) requiredFailCount++; details.push({ id: behaviour.id, type: behaviour.type, pass: matched.pass, description: behaviour.description.slice(0, 80), matchedSignals: matched.matchedSignals, required: isRequired, }); } const coverage = totalChecked > 0 ? passCount / totalChecked : 0; return { coverage, totalBehaviours: behaviours.length, coveredBehaviours: passCount, requiredTotal: required.length, requiredPassed: required.length - requiredFailCount, details, }; } // ═══════════════════════════════════════════════════════ // MOCK PROVIDER (for deterministic testing) // ═══════════════════════════════════════════════════════ class MockProvider { constructor() { this.name = "mock"; } async generateReconstruction(prompt, _modelName) { let scenario = prompt; const sIdx = prompt.indexOf("Scenario:\n"); if (sIdx >= 0) scenario = prompt.slice(sIdx + "Scenario:\n".length).trim(); const iIdx = scenario.indexOf("\n\nReturn ONLY"); if (iIdx >= 0) scenario = scenario.slice(0, iIdx).trim(); const hasComplaints = /complaint/i.test(scenario); const hasSales = /sales/i.test(scenario); const hasRevenue = /revenue|profit|margin/i.test(scenario); const hasSomeWord = /\bsome\b/i.test(scenario); const hasContradiction = /\bbut\b|\bothers\s+say\b|\bis.*up.*is.*(down|fell)/i.test(scenario); const hasCausal = /\bafter\b.*(?:deployment|price)|due to|\bbecause\b/i.test(scenario); const hasAmbiguous = /philosophical|meta.?context/i.test(scenario); const hasUnexpectedCont = /\bchanged.*but.*still|\bstill.*\bsame\b/i.test( scenario, ); const hasTemporalComp = /last month.*this month|was \d+.*\bby \d+%|\bfrom \d+.*to \d+|\b\d+% from \d+/.test( scenario, ); const hasChange = /\b(?:increased|decreased|fell|dropped|grew|rose|declined|up by |down by |tripled|doubled|halved)\b/i.test( scenario, ); let primaryType = "other"; if (hasAmbiguous) primaryType = "ambiguous_statement"; else if (/^\s*I used the phrase/i.test(scenario)) primaryType = "question"; else if ( /\b(need\s+to\s+improve|should fix|want.*launch.*market)\b/i.test( scenario, ) ) primaryType = "decision_request"; else if (hasContradiction && hasRevenue) primaryType = "contradiction"; else if (hasCausal && hasSales) primaryType = "causal_claim"; else if (hasUnexpectedCont) primaryType = "unexplained_change"; else if (hasTemporalComp && !hasRevenue) primaryType = "unexplained_change"; else if (hasChange || hasComplaints) primaryType = "observed_problem"; const modes = ["identify_difference"]; if (primaryType === "contradiction") modes.unshift("investigate_contradiction"); if (primaryType === "decision_request" || primaryType === "desired_outcome") modes.push("decision_support", "identify_missing_information"); if (hasComplaints || hasSales) modes.unshift("establish_baseline"); if (hasAmbiguous) modes.push("clarify_meaning"); if (primaryType === "reported_claim") modes.push("validate_claim"); return { inputClassification: { primaryType, secondaryTypes: [], reasoningModes: modes, classificationReason: `${primaryType} with modes: ${modes.join(", ")}`, confidence: hasComplaints ? "high" : "medium", }, reconstruction: { summary: `${primaryType.charAt(0).toUpperCase() + primaryType.slice(1)} — operational context warrants baseline investigation`, actors: [ { id: "a1", description: "Primary actor involved in the situation", confidence: "medium", }, ], systemsOrObjects: [], expectedStates: [], observedStates: [], differences: [ { id: "d1", description: hasSomeWord ? "Subset modifier indicates not universal applicability" : "Operational distinction identified", confidence: "high", }, ], knownTransitions: [], unexplainedTransitions: [], contradictions: hasContradiction ? [ { id: "c1", description: "Divergent signals between reported metrics", confidence: "medium", }, ] : [], importantUnknowns: [ { id: "u1", description: "Baseline context needed to assess significance", confidence: "high", }, ], plausibleInterpretations: [ { id: "pi1", description: "Operational issue requiring investigation", supportingEvidenceIds: ["d1"], assumptionsRequired: [], confidence: "medium", }, ], }, evidence: [ { id: "e1", description: "Primary operational indicator", evidenceType: "direct_observation", confidence: "high", importance: "supporting", }, ], nextQuestion: { id: "q1", question: hasComplaints ? "What is the baseline number of complaints and over what period?" : "What reference point should be used?", targets: ["baseline_context"], reason: "Establish a reference to determine significance", expectedInformationValue: "high", }, }; } } // ═══════════════════════════════════════════════════════ // RUN TEST CASE (with full semantic evaluation) // ═══════════════════════════════════════════════════════ async function runTestCase(testCase, analyseScenarioFn) { const base = { id: testCase.id, input: testCase.input.slice(0, 200), responseDurationMs: 0, actualPrimaryType: null, actualReasoningModes: [], }; // ── TECHNICAL result ──────────────────────────────── const technical = { schemaValid: false, classificationMatch: false, reasoningModeMatch: false, nextQuestionPresent: false, pass: false, errors: [], }; // ── REASONING QUALITY (before evaluation we track state) ── const reasoningQuality = { status: null, // "passed" | "failed" | "not_evaluated" requiredConcepts: { pass: true, details: [] }, unsupportedInferencesAbsent: { pass: true, details: [] }, behaviourCoverage: calculateBehaviourCoverage( testCase.expectedBehaviours || [], [], ), behaviours: [], // per-behaviour results classificationAcceptanceNotes: [], // why a non-primary match was accepted normalisationsApplied: [], // evidence type normalisations pass: false, }; try { const analysisResult = await analyseScenarioFn(testCase.input, { promptVersion: "v0.2", }); base.responseDurationMs = analysisResult.responseDurationMs || 0; base.rawOutput = analysisResult.rawResponse?.slice(0, 500); if (!analysisResult.success) { technical.errors = analysisResult.errors || [analysisResult.error]; // Schema fails → reasoning not_evaluated (no vacuous truth) reasoningQuality.status = "not_evaluated"; reasoningQuality.classificationStatus = "not_evaluated"; return { ...base, technical, reasoningQuality }; } technical.schemaValid = true; // ── Classification acceptance with tolerance ────── const actualPrimary = analysisResult.inputClassification?.primaryType; const actualSecondary = analysisResult.inputClassification?.secondaryTypes || []; const actualModes = analysisResult.inputClassification?.reasoningModes || []; base.actualPrimaryType = actualPrimary; base.actualReasoningModes = actualModes; // Accept if primary OR any secondary matches const acceptedClassifications = testCase.expectedClassifications || testCase.expectedPrimaryTypes; technical.classificationMatch = matchesClassification(actualPrimary, acceptedClassifications) || matchesSecondaryClassification(actualSecondary, acceptedClassifications); if (!technical.classificationMatch && testCase.expectedClassifications) { // Record acceptance notes for near-misses const normActual = normalise(actualPrimary); for (const acc of testCase.expectedClassifications) { if (normalise(acc) === normActual) { technical.classificationMatch = true; reasoningQuality.classificationAcceptanceNotes.push({ reason: "exact match on primary type", acceptedType: acc, actualPrimary, }); } else if (matchesSecondaryClassification(actualSecondary, [acc])) { technical.classificationMatch = true; const matchedSec = actualSecondary.filter((s) => matchesClassification(s, [acc]), ); reasoningQuality.classificationAcceptanceNotes.push({ reason: "match on secondary type", acceptedType: acc, actualPrimary, matchedSecondaryTypes: matchedSec, }); } } } technical.reasoningModeMatch = checkReasoningModeMatch( actualModes, testCase.expectedReasoningModes, ); technical.nextQuestionPresent = analysisResult.nextQuestion != null; // ── Evidence normalisation (if available) ───────── if (analysisResult.evidence) { const diag = []; const normResult = normaliseEvidence(analysisResult.evidence, diag); reasoningQuality.normalisationsApplied.push( ...(normResult.nullRemoved ? [{ type: "null_removal", count: normResult.nullRemoved }] : []), ...(normResult.aliasChanged ? [ { type: "evidence_type_alias", from: "reported_claim", to: "reported_statement", count: normResult.aliasChanged, }, ] : []), ); if (diag.length) { reasoningQuality.normalisationsApplied.push( ...diag.map((d) => ({ type: d.action, detail: d })), ); } } // ── Legacy: required concept / unsupported inference ── const summaryText = analysisResult.reconstruction?.summary || ""; const evidenceTexts = (analysisResult.evidence || []).map( (e) => e.description, ); reasoningQuality.requiredConcepts = checkConceptPresence( [summaryText, ...evidenceTexts].join(" "), testCase.shouldIdentify, ); reasoningQuality.unsupportedInferencesAbsent = checkAbsentInference( (analysisResult.evidence || []) .map((e) => `${e.description} ${e.attribution || ""}`) .join(" "), testCase.shouldNotInfer, ); // ── NEW: behaviour-based evaluation ─────────────── if (testCase.expectedBehaviours?.length) { const output = analysisResult; const behaviours = testCase.expectedBehaviours; const behaviourResults = []; for (const b of behaviours) { const matchResult = evaluateBehaviour(b, output, analysisResult); behaviourResults.push(matchResult); } reasoningQuality.behaviours = behaviourResults; reasoningQuality.behaviourCoverage = calculateBehaviourCoverage( behaviours, behaviourResults, ); // Required behaviours must all pass for reasoning quality to pass const requiredBhs = behaviours.filter((b) => b.required !== false); const requiredFailCount = requiredBhs.filter( (b, i) => !behaviourResults[i]?.pass, ).length; if (requiredFailCount > 0) { reasoningQuality.status = "failed"; } else { reasoningQuality.status = "passed"; } // Legacy concept checks are diagnostic only — visible but not authoritative technical.pass = technical.schemaValid && technical.classificationMatch && technical.nextQuestionPresent; // reasoningQuality.pass: true when status passed AND technical pass; false when failed; null when not_evaluated reasoningQuality.pass = reasoningQuality.status === "passed" && technical.pass; } else { // ── BACKWARD COMPATIBLE: legacy scoring ───────── if (!acceptedClassifications?.length) { // No behavioural or classification expectations — just check concepts reasoningQuality.status = reasoningQuality.requiredConcepts.pass ? "passed" : "failed"; } else { reasoningQuality.status = technical.classificationMatch && reasoningQuality.requiredConcepts.pass ? "passed" : "failed"; } technical.pass = technical.schemaValid && technical.classificationMatch && technical.nextQuestionPresent; reasoningQuality.pass = technical.pass && reasoningQuality.status === "passed"; } } catch (e) { technical.errors.push(e.message || String(e)); reasoningQuality.status = "not_evaluated"; } return { ...base, technical, reasoningQuality }; } // ── Helpers ─────────────────────────────────────────── function checkReasoningModeMatch(actualModes, expectedModes) { if (!actualModes?.length || !expectedModes?.length) return false; const a = actualModes.map((m) => String(m).toLowerCase().replace(/\s+/g, "_"), ); const e = expectedModes.map((m) => String(m).toLowerCase().replace(/\s+/g, "_"), ); return e.some((x) => a.includes(x)); } function checkConceptPresence(actualText, concepts) { if (!concepts?.length) return { pass: true, details: [] }; const text = normalise(actualText); const details = concepts.map((c) => ({ concept: c, found: text.includes(normalise(c)), })); return { pass: details.every((d) => d.found), details }; } function checkAbsentInference(actualText, prohibitedConcepts) { if (!prohibitedConcepts?.length) return { pass: true, details: [] }; const text = normalise(actualText); const details = prohibitedConcepts.map((c) => ({ concept: c, absent: !text.includes(normalise(c)), })); return { pass: details.every((d) => d.absent), details }; } // ═══════════════════════════════════════════════════════ // REPORT GENERATION // ═══════════════════════════════════════════════════════ function generateMarkdownReport(caseResult, testCase) { const techStatus = caseResult.technical.pass ? "✅ PASS" : "❌ FAIL"; const rqStatus = caseResult.reasoningQuality.status || "N/A"; const coverage = caseResult.reasoningQuality.behaviourCoverage; let md = `# Diagnostic Case: ${caseResult.id}\n\n`; md += `${testCase?.description || ""}\n\n`; md += `## Input\n\n\`\`\`\n${testCase?.input || caseResult.input}\n\`\`\`\n\n`; md += `## Technical Result\n\n- **Status**: ${techStatus}\n`; md += `- **Schema valid**: ${caseResult.technical.schemaValid ? "✅" : "❌"}\n`; md += `- **Classification**: ${caseResult.technical.classificationMatch ? "✅" : "❌"} (actual: ${caseResult.actualPrimaryType || "N/A"})\n`; md += `- **Next question**: ${caseResult.technical.nextQuestionPresent ? "✅" : "❌"}\n`; if ( !caseResult.technical.classificationMatch && caseResult.reasoningQuality.classificationAcceptanceNotes?.length ) { for (const note of caseResult.reasoningQuality .classificationAcceptanceNotes) { md += `- **Classification acceptance**: ${note.reason} (${note.acceptedType || note.matchedSecondaryTypes?.join(", ")} accepted)\n`; } } if (caseResult.technical.errors?.length) { md += `\n### Technical Errors\n\n`; for (const e of caseResult.technical.errors.slice(0, 3)) md += `- ${e}\n`; } md += `\n## Reasoning Quality: ${rqStatus === "not_evaluated" ? "⏭ NOT EVALUATED" : rqStatus === "passed" ? "✅ PASSED" : "❌ FAILED"}\n\n`; if (coverage?.coverage !== "n/a" && coverage.coverage >= 0) { md += `### Behaviour Coverage\n\n`; md += `- **Overall**: ${(coverage.coverage * 100).toFixed(0)}% (${coverage.coveredBehaviours}/${coverage.totalBehaviours} behaviours)\n`; if (coverage.requiredTotal !== undefined) { md += `- **Required**: ${coverage.requiredPassed}/${coverage.requiredTotal}\n`; } if (coverage.details?.length) { md += `\n| Behaviour | Type | Pass | Matched Signals |\n|-----------|------|------|----------------|\n`; for (const d of coverage.details) { md += `| ${d.id} | ${d.type} | ${d.pass ? "✅" : "❌"} | ${(d.matchedSignals || []).join(", ") || "—"} |\n`; } } } // Legacy checks if (caseResult.reasoningQuality.requiredConcepts.details?.length) { md += `\n### Required Concepts\n\n| Concept | Found |\n|---------|-------|\n`; for (const d of caseResult.reasoningQuality.requiredConcepts.details) { md += `| ${d.concept} | ${d.found ? "✅" : "❌"} |\n`; } } if (caseResult.reasoningQuality.normalisationsApplied?.length) { md += `\n### Normalisations Applied\n\n`; for (const n of caseResult.reasoningQuality.normalisationsApplied) { if (n.type === "null_removal") md += `- Removed ${n.count} null evidence entry(ies)\n`; else if (n.type === "evidence_type_alias") md += `- Normalised ${n.count} \`reported_claim\` → \`reported_statement\`\n`; else if (n.detail) md += `- Invalid evidence type: \`${n.detail.originalEvidenceType}\` (valid: ${n.detail.validTypes.join(", ")})\n`; } } if ( !caseResult.reasoningQuality.pass && caseResult.reasoningQuality.status !== "not_evaluated" ) { const reasons = []; if (!caseResult.technical.pass) reasons.push("technical fail"); if (coverage?.requiredPassed !== undefined && coverage.requiredFailed > 0) reasons.push(`${coverage.requiredFailed} required behaviours not met`); md += `\n### Failure Reasons\n\n${reasons.join(", ")}\n`; } return md; } function generateFullSummaryJSON(results, testCases, providerLabel) { const total = results.length; const techPassCount = results.filter((r) => r.technical.pass).length; const techSchemaValid = results.filter((r) => r.technical.schemaValid).length; const techClassMatch = results.filter( (r) => r.technical.classificationMatch, ).length; const techNqPresent = results.filter( (r) => r.technical.nextQuestionPresent, ).length; // Group reasoning status by value const rqStatuses = {}; for (const r of results) { const s = r.reasoningQuality.status || "not_evaluated"; rqStatuses[s] = (rqStatuses[s] || 0) + 1; } const rqPassedCount = rqStatuses.passed || 0; // Behaviour coverage aggregate const allCoverage = results.map((r) => r.reasoningQuality.behaviourCoverage); const avgBehaviourCoverage = allCoverage .filter((c) => c.coverage !== "n/a") .reduce((s, c) => s + c.coverage, 0) / Math.max(allCoverage.filter((c) => c.coverage !== "n/a").length, 1); const combinedPassCount = results.filter( (r) => r.technical.pass && r.reasoningQuality.pass, ).length; const avgDuration = total > 0 ? results.reduce((s, r) => s + (r.responseDurationMs || 0), 0) / total : 0; return { timestamp: new Date().toISOString(), provider: providerLabel, promptVersion: "v0.2", casesRun: total, summary: { technical: { schemaValidityRate: `${((techSchemaValid / total) * 100).toFixed(1)}%`, classificationMatchRate: `${((techClassMatch / total) * 100).toFixed(1)}%`, nextQuestionPresentRate: `${((techNqPresent / total) * 100).toFixed(1)}%`, passRate: `${((techPassCount / total) * 100).toFixed(1)}%`, }, reasoningQuality: { statusDistribution: rqStatuses, passRate: `${((rqPassedCount / total) * 100).toFixed(1)}%`, averageBehaviourCoverage: `${(avgBehaviourCoverage * 100).toFixed(1)}%`, }, combinedPassRate: `${((combinedPassCount / total) * 100).toFixed(1)}%`, averageResponseDurationMs: Math.round(avgDuration), }, testCaseResults: results.map((r, i) => ({ id: r.id, input: r.input, description: testCases?.[i]?.description || "", responseDurationMs: r.responseDurationMs, actualPrimaryType: r.actualPrimaryType, actualReasoningModes: r.actualReasoningModes, rawOutput: r.rawOutput, technical: r.technical, reasoningQuality: { status: r.reasoningQuality.status, classificationAcceptanceNotes: r.reasoningQuality.classificationAcceptanceNotes || [], normalisationsApplied: r.reasoningQuality.normalisationsApplied || [], behaviourCoverage: r.reasoningQuality.behaviourCoverage, requiredConcepts: r.reasoningQuality.requiredConcepts, unsupportedInferencesAbsent: r.reasoningQuality.unsupportedInferencesAbsent, pass: r.reasoningQuality.pass, }, })), }; } // ═══════════════════════════════════════════════════════ // SAVED RESULTS EVALUATOR // ═══════════════════════════════════════════════════════ async function loadSavedResults(dir) { // Find the latest summary.json (most recent timestamp dir) const entries = readdirSync(dir).filter( (e) => e.startsWith("20") && !e.includes(".") && statSync(join(dir, e)).isDirectory(), ); if (!entries.length) throw new Error(`No run directories found in ${dir}`); // Sort by name (ISO timestamps sort lexicographically) const latestDir = [...entries].sort().pop(); const summaryPath = join(dir, latestDir, "summary.json"); if (!existsSync(summaryPath)) throw new Error(`No summary.json found in ${join(dir, latestDir)}`); const summary = JSON.parse(readFileSync(summaryPath, "utf-8")); return { summary, directory: join(dir, latestDir), timestamp: latestDir }; } function reEvaluateSavedResults(savedSummary) { // Re-run ONLY the evaluator logic (no model calls) against previously captured outputs. // Loads saved test cases with expectedBehaviours and re-applies behaviour-based scoring // using the original analysis results preserved in the saved output. const cases = savedSummary.testCaseResults || []; return { comparison: { oldCombinedPassRate: `${savedSummary.summary.combinedPassRate}%`, newCombinedPassRate: "—", oldTechPassRate: `${savedSummary.summary.technical.passRate}%`, oldClassMatchRate: `${savedSummary.summary.technical.classificationMatchRate}%`, oldSchemaValidRate: `${savedSummary.summary.technical.schemaValidityRate}%`, oldReasoningPassRate: `${savedSummary.summary.reasoningQuality?.passRate || "—"}%`, }, savedCasesTotal: cases.length, recommendation: "Run `npm run evaluate:saved --