batchCooking/experiments/llm-tech-step-poc/src/shared/benchmark-harness.ts
Nicolas c73c62328d refactor(experiments): retire le script apps/api, ajoute NLP frais + pipeline hybride
Étape 1 — retire apps/api/src/scripts/bench-tech-step-classifier.ts
(DB-backed, taxonomie ~26 techniques non comparable terme à terme au LLM).

Étape 2 — reconstruit tout dans experiments/llm-tech-step-poc, entièrement
autonome (aucune dépendance Postgres/apps/api) :

- shared/kitchen-action.ts, shared/test-sentences.ts,
  shared/benchmark-harness.ts : types, 7 phrases de test et harness de
  mesure/affichage désormais partagés par les trois scripts (plus de
  recopie manuelle entre fichiers).
- nlp-tech-step-poc.ts : classifieur node-nlp FRAIS (NER + clauses +
  classification), entraîné directement sur la taxonomie à 7 catégories du
  LLM plutôt que réutiliser TechStepClassifierService — comparaison terme à
  terme, et surtout un score de confiance BRUT jamais masqué (contrairement
  au repli silencieux sur l'ancre NER de la version production), condition
  nécessaire au pipeline hybride. Corpus qui préfère les synonymes mono-mot
  ("revenir") aux phrases figées, pour ne pas se faire piéger par les
  pronoms clitiques français ("faites-les-revenir").
- hybrid-tech-step-poc.ts : NLP toujours en premier (chemin rapide), LLM en
  secours si la confiance NLP passe sous NLP_TRUST_THRESHOLD (0.6, tunable)
  ou qu'aucune action n'est trouvée — récapitulatif avec colonnes "moteur"
  et "confiance NLP" pour observer les bascules.
- llm-tech-step-poc.ts : inchangé fonctionnellement, migré vers les modules
  partagés.
- shared/module-entry.ts (isMainModule) : garde chaque script pour que
  l'import de ses classes (par hybrid-tech-step-poc.ts) ne déclenche pas
  aussi son propre benchmark comme effet de bord.

pnpm bench / bench:nlp / bench:hybrid. README réécrit en conséquence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 21:13:20 +02:00

138 lines
7.1 KiB
TypeScript

/**
* Harness de benchmark partagé par les trois moteurs de ce PoC — logs
* itératifs par répétition, mesure latence/RSS, tableau récapitulatif.
* Générique sur `TResult` (la forme de sortie de chaque moteur diffère :
* `RecipeStepAnalysis` pour le LLM, `NlpStepAnalysis` pour le classifieur
* NLP, `HybridStepAnalysis` pour le pipeline hybride) pour que les trois
* scripts réutilisent exactement le même code de mesure/affichage plutôt
* que de le tripler.
*/
import { performance } from "node:perf_hooks";
import type { BenchmarkSentence } from "./test-sentences.js";
import { TEST_SENTENCES } from "./test-sentences.js";
/** Nombre de répétitions mesurées par phrase — même valeur pour les trois moteurs, pour des runs comparables. */
export const REPETITIONS_PER_SENTENCE = 3;
/** Une mesure individuelle (une répétition, une phrase) — la matière première des tableaux récapitulatifs. */
export interface BenchmarkSample<TResult> {
sentence: BenchmarkSentence;
latencyMs: number;
/** Delta de RSS du process Node entre juste avant et juste après cet appel — une approximation de la RAM réellement consommée : `process.memoryUsage()` ne voit que le tas V8, mais un binding natif (llama.cpp) alloue dans le même process, donc le RSS (mémoire résidente totale du process) le capture, au bruit du GC près. */
rssDeltaBytes: number;
result: TResult;
}
/** Formate un delta de RSS en Mo avec un signe explicite (`+`/`-`), pour l'affichage. */
export function formatRssDelta(rssDeltaBytes: number): string {
const megabytes = rssDeltaBytes / (1024 * 1024);
return `${megabytes >= 0 ? "+" : ""}${megabytes.toFixed(1)} Mo`;
}
/** Paramètres de {@link runBenchmark} — un par moteur (LLM/NLP/hybride), voir chaque appelant. */
export interface BenchmarkRunOptions<TResult> {
/** Préfixe des logs itératifs, ex. `"[poc]"`, `"[nlp]"`, `"[hybrid]"`. */
logPrefix: string;
/** Nombre d'éléments détectés dans un résultat — alimente le log par répétition et la colonne de comptage du récapitulatif. */
countOf: (result: TResult) => number;
/** Libellé de ce qui est compté, ex. `"action(s) détectée(s)"` ou `"technique(s) détectée(s)"`. */
countLabel: string;
/** Lance une analyse pour une phrase donnée. Une erreur est journalisée et n'interrompt pas les répétitions suivantes — un run qui plante entièrement à la première réponse mal formée serait bien moins utile qu'un rapport partiel. */
analyze: (sentence: BenchmarkSentence) => Promise<TResult>;
}
/**
* Exécute {@link REPETITIONS_PER_SENTENCE} analyses par phrase de
* {@link TEST_SENTENCES} et renvoie toutes les mesures individuelles,
* journalisant chaque répétition au fur et à mesure (avant ET après)
* plutôt que de rester muet jusqu'au récapitulatif final : un run complet
* peut prendre plusieurs minutes, et savoir où on en est — quelle phrase,
* quelle répétition, le résultat qui vient de tomber — vaut largement le
* bruit de sortie supplémentaire pour ces scripts de benchmark
* (contrairement au code applicatif, où `console` est réservé à
* `LoggerService` — n'existe pas ici, PoC autonome sans app autour).
*/
export async function runBenchmark<TResult>(
options: BenchmarkRunOptions<TResult>,
): Promise<BenchmarkSample<TResult>[]> {
const { logPrefix, countOf, countLabel, analyze } = options;
const samples: BenchmarkSample<TResult>[] = [];
const totalRuns = TEST_SENTENCES.length * REPETITIONS_PER_SENTENCE;
let runIndex = 0;
for (const [sentenceIndex, sentence] of TEST_SENTENCES.entries()) {
for (let repetition = 1; repetition <= REPETITIONS_PER_SENTENCE; repetition++) {
runIndex++;
console.info(
`${logPrefix} (${runIndex}/${totalRuns}) phrase ${sentenceIndex + 1}/${TEST_SENTENCES.length} "${sentence.id}" (${sentence.locale}) — répétition ${repetition}/${REPETITIONS_PER_SENTENCE}...`,
);
const rssBefore = process.memoryUsage().rss;
const startedAt = performance.now();
try {
const result = await analyze(sentence);
const latencyMs = performance.now() - startedAt;
const rssDeltaBytes = process.memoryUsage().rss - rssBefore;
samples.push({ sentence, latencyMs, rssDeltaBytes, result });
console.info(
`${logPrefix} -> ${latencyMs.toFixed(0)} ms, ${countOf(result)} ${countLabel}, RSS ${formatRssDelta(rssDeltaBytes)}`,
);
} catch (err) {
console.error(
`${logPrefix} -> échec sur "${sentence.id}" (répétition ${repetition})`,
err,
);
}
}
}
return samples;
}
/** Une colonne supplémentaire du tableau récapitulatif, au-delà des colonnes communes — ex. la colonne "moteur" du pipeline hybride. */
export interface SummaryExtraColumn<TResult> {
label: string;
/** Calculée à partir de la DERNIÈRE répétition de la phrase — même logique que la colonne de comptage commune, voir {@link printSummaryTable}. */
valueOf: (lastSample: BenchmarkSample<TResult>) => string | number;
}
/**
* Agrège des {@link BenchmarkSample}s par phrase et imprime le tableau
* récapitulatif du benchmark (latence moyenne/min/max, delta RSS moyen,
* nombre d'éléments détectés, plus toute colonne additionnelle spécifique
* au moteur). Le compte d'éléments détectés est pris sur la DERNIÈRE
* répétition plutôt que moyenné : un nombre d'actions n'a pas de moyenne
* sensée (une info qualitative, pas une mesure continue) — la dernière
* répétition sert d'échantillon représentatif, comme dans les runs
* précédents de ce PoC.
*/
export function printSummaryTable<TResult>(
samples: readonly BenchmarkSample<TResult>[],
countOf: (result: TResult) => number,
countColumnLabel: string,
extraColumns: readonly SummaryExtraColumn<TResult>[] = [],
): void {
const rows = TEST_SENTENCES.map((sentence) => {
const sentenceSamples = samples.filter((sample) => sample.sentence.id === sentence.id);
const latencies = sentenceSamples.map((sample) => sample.latencyMs);
const avgLatency = latencies.reduce((sum, value) => sum + value, 0) / (latencies.length || 1);
const avgRssMb =
sentenceSamples.reduce((sum, sample) => sum + sample.rssDeltaBytes, 0) /
(sentenceSamples.length || 1) /
(1024 * 1024);
const lastSample = sentenceSamples.at(-1);
const row: Record<string, string | number> = {
phrase: sentence.id,
langue: sentence.locale,
"runs OK": sentenceSamples.length,
"latence moy. (ms)": latencies.length > 0 ? avgLatency.toFixed(0) : "—",
"latence min (ms)": latencies.length > 0 ? Math.min(...latencies).toFixed(0) : "—",
"latence max (ms)": latencies.length > 0 ? Math.max(...latencies).toFixed(0) : "—",
"RSS moy. (Mo)": sentenceSamples.length > 0 ? avgRssMb.toFixed(1) : "—",
[countColumnLabel]: lastSample !== undefined ? countOf(lastSample.result) : 0,
};
for (const column of extraColumns) {
row[column.label] = lastSample !== undefined ? column.valueOf(lastSample) : "—";
}
return row;
});
console.info("\n=== Récapitulatif ===");
console.table(rows);
}