/** * Precision/recall/F1 for {@link techStepClassifier}'s output against a * hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective * counterpart to the "inspected by eye" verdict every corpus change used to * get before this module existed. Every future edit to * `tech-step-training-data.ts` (including the LLM-assisted suggestions the * worker in `services/tech-step-llm-worker` proposes) is expected to run * through `tech-step-eval.test.ts`'s regression gate, which calls * {@link computeTechStepMetrics} — a corpus change that raises recall on one * technique but silently tanks another's precision should fail loudly here, * not get merged on the strength of a few manually-checked examples. * * Pure (no DB/model access) so it's unit-testable on its own — same * convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`, * `splitIntoClauses`): this module only ever receives already-resolved * `TechStep.key` strings, never DB ids or a live classifier instance, so it * has nothing to mock to test. */ /** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */ export interface TechStepMetrics { truePositives: number; falsePositives: number; falseNegatives: number; precision: number; recall: number; f1: number; } /** * One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys} * said should be found, against what the classifier actually returned for * that case (already mapped from `TechStepMatch.techStepId` back to * `TechStep.key`, see `tech-step-eval.test.ts`). * * Both lists are *multisets*, not sets — a description that names the same * technique twice (rare, but not impossible: "faire cuire, puis... remettre * à cuire") is expected to produce two matches, and comparing as plain sets * would silently treat a classifier that only found one of them as a * perfect match. */ export interface TechStepEvalOutcome { expectedKeys: string[]; actualKeys: string[]; } /** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */ export interface TechStepEvalResult { overall: TechStepMetrics; byKey: Record; } interface RawCounts { tp: number; fp: number; fn: number; } function emptyCounts(): RawCounts { return { tp: 0, fp: 0, fn: 0 }; } /** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */ function countByKey(keys: string[]): Map { const counts = new Map(); for (const key of keys) { counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; } /** * Standard vacuous-truth convention for the `0/0` cases: precision defaults * to `1` when nothing was predicted for a key (`tp + fp === 0` — no false * accusation to be precise about), recall defaults to `1` when nothing was * expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1 * on its own: a technique the classifier fully misses still has `recall = * 0` (there *were* expected occurrences, just none matched), which is what * pulls F1 down to `0` for that case regardless of precision's vacuous `1`. */ function toMetrics(counts: RawCounts): TechStepMetrics { const { tp, fp, fn } = counts; const precision = tp + fp === 0 ? 1 : tp / (tp + fp); const recall = tp + fn === 0 ? 1 : tp / (tp + fn); const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall); return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 }; } /** * Aggregates every {@link TechStepEvalOutcome} into one overall * precision/recall/F1 plus a per-technique breakdown. * * Counted key by key, multiset-style, per outcome: for a given technique, * `min(expectedCount, actualCount)` true positives, any actual occurrences * beyond that are false positives, any expected occurrences short of that * are false negatives — generalizes the usual set-based TP/FP/FN definition * to handle a technique mentioned (or matched) more than once in the same * step without over- or under-counting it. */ export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult { const overallCounts = emptyCounts(); const countsByKey = new Map(); for (const outcome of outcomes) { const expectedCounts = countByKey(outcome.expectedKeys); const actualCounts = countByKey(outcome.actualKeys); const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]); for (const key of allKeys) { const expected = expectedCounts.get(key) ?? 0; const actual = actualCounts.get(key) ?? 0; const tp = Math.min(expected, actual); const fp = Math.max(0, actual - expected); const fn = Math.max(0, expected - actual); overallCounts.tp += tp; overallCounts.fp += fp; overallCounts.fn += fn; const keyCounts = countsByKey.get(key) ?? emptyCounts(); keyCounts.tp += tp; keyCounts.fp += fp; keyCounts.fn += fn; countsByKey.set(key, keyCounts); } } const byKey: Record = {}; for (const [key, counts] of countsByKey) { byKey[key] = toMetrics(counts); } return { overall: toMetrics(overallCounts), byKey }; }