/** * 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 { 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 { /** 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; } /** * 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( options: BenchmarkRunOptions, ): Promise[]> { const { logPrefix, countOf, countLabel, analyze } = options; const samples: BenchmarkSample[] = []; 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 { 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) => 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( samples: readonly BenchmarkSample[], countOf: (result: TResult) => number, countColumnLabel: string, extraColumns: readonly SummaryExtraColumn[] = [], ): 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 = { 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); }