ADRADR-0008 · Le harnais d'eval est vitest, pas un framework

ADR-0008 — Le harnais d'eval est vitest, pas un framework

5 900 tests, zero eval (ai-platform/0138). La suite couvre le deterministe — idempotence de facturation, cloisonnement tenant, heal du schema, webhooks Stripe, gardes de credits — et rien ne mesure ce qu'@Atlas et les…

Statut

Accepté · 2026-09-05

Piliers : ai-platform

Contexte

5 900 tests, zero eval (ai-platform/0138). La suite couvre le deterministe — idempotence de facturation, cloisonnement tenant, heal du schema, webhooks Stripe, gardes de credits — et rien ne mesure ce qu'@Atlas et les cinq specialistes repondent.

C'est le seul sous-systeme sans filet, et c'est celui qu'on vend. La promesse produit n'est pas « la facturation est idempotente », c'est « l'IA opere le business ».

Quatre choses ne sont attrapees par rien aujourd'hui :

  1. une regression de prompt qui degrade le routage vers le bon specialiste ;
  2. un outil appele avec de mauvais arguments dans un cas limite ;
  3. une reponse qui affirme comme observe un chiffre qui etait estime — la faute que tout le pipeline Intelligence combat cote donnees, et que rien ne surveille cote generation ;
  4. une derive apres un changement de modele ou de version de passerelle.

L'item interdisait explicitement de trancher sur un article de blog.

Options

(a) DeepEval

Le plus complet sur les agents et la securite, TypeScript desormais open source. Il apporte son propre vocabulaire de metriques (GEval, AnswerRelevancy, Faithfulness), son propre runner, et une ecologie qui vient du monde Python.

Ce qu'il aurait rendu : des metriques pretes, une nomenclature partagee avec le reste de l'industrie.

Ce qu'il coutait : un second runner a cote de vitest, un second endroit ou une CI peut echouer, et une couche d'abstraction entre nos quatre cas et ce qui est reellement envoye au modele. Pour quatre familles de cas, le vocabulaire coute plus qu'il ne rend.

(b) Promptfoo

Declaratif, matriciel, excellent en CI. Les cas vivent en YAML.

Ce qu'il coutait, et c'est redhibitoire ici : nos assertions ne sont pas des comparaisons de chaines. « Le bon specialiste a ete appele » se lit dans result.toolCalls, pas dans le texte. « L'argument storeId est celui qu'on a donne, pas un placeholder » non plus. Exprimer ca en YAML demande des assertions JavaScript inline — c'est-a-dire du JavaScript, dans du YAML, sans typage.

Et un second langage de configuration est une seconde source de verite sur ce que le repo teste.

(c) Un socle sur vitest

Un eval est un test avec un juge non deterministe et un seuil. Le repo a deja 5 900 tests, une culture de gardes derivees (docs:claims, intel:coverage, fleet:scope) et un vi.mock par surface.

Décision

(c). evals/ a la racine, vitest.evals.config.ts, pnpm evals.

Trois raisons, dans l'ordre :

  1. Les assertions qui comptent sont deterministes. Trois de nos quatre familles — routage, selection d'outil, refus — se verifient en lisant toolCalls et les arguments. Elles n'ont pas besoin d'un juge, elles ont besoin d'un modele reel et d'un expect. C'est exactement un test vitest avec une latence.
  2. Le juge n'est utile que sur une famille. L'honnetete des chiffres est une propriete textuelle : elle demande un modele qui lit une regle et rend un verdict. Ca tient en trente lignes (support/harness.ts, judge()), et l'ecrire nous-memes nous a forces a une chose qu'aucun framework n'impose : la rubrique est adverse par construction — le juge recoit l'ordre de trouver la violation, et le defaut est false. Un juge a qui on demande « est-ce que ca va ? » repond oui.
  3. La separation est la propriete de securite. vitest.config.ts ne collecte que src/**. Rien dans evals/ n'est joignable depuis pnpm test, depuis le hook pre-push, ni depuis le job CI qui garde chaque PR. Un eval appelle un modele ; un modele coute de l'argent. Un framework tiers aurait rendu cette frontiere dependante de SA configuration.

Conséquences

Ce qui est livre

FichierRole
evals/support/harness.tsevalCase, ask, askWithTools, judge, le compteur de depense
evals/routing.eval.tsfamille 1 — 7 cas, dont un piege lexical
evals/number-honesty.eval.tsfamille 2 — 4 cas, juge adverse
evals/tool-selection.eval.tsfamille 3 — 4 cas
evals/refusal.eval.tsfamille 4 — 3 cas
vitest.evals.config.tsle runner, fileParallelism: false
.github/workflows/evals.ymlcadence hebdomadaire + declenchement manuel
src/test/evals-are-not-in-the-default-suite.test.tsla frontiere, derivee

Trois regles, tenues par du code et pas par une convention

  1. Jamais par accident. La garde dans src/test/ echoue si un fichier .eval.ts devient joignable depuis vitest.config.ts, si pnpm evals entre dans le hook pre-push, ou si le workflow CI par defaut l'appelle.
  2. Gratuit hors ligne. Sans AI_GATEWAY_API_KEY, chaque cas saute avec une raison lisible. Un contributeur dans un avion voit des skips, pas des echecs, et ne paie rien.
  3. Plafonne. EVAL_USD_BUDGET (defaut $0.50) borne la course entiere. Le compteur additionne les tokens de tous les appels, sujet et juge, et la course s'arrete quand le plafond est atteint. Le prix est une constante locale et non une lecture de model-pricing.ts : une baisse de prix ne doit pas relever le plafond en silence.

Ce que ca ne fait pas

Ca ne mesure pas la qualite d'une reponse longue, ni la satisfaction. Ca mesure quatre proprietes verifiables. Un eval qui note « la reponse est-elle bonne ? » produit un nombre que personne ne sait interpreter et qui ne dit jamais quoi corriger.

Le sujet et le juge tournent tous deux sur le tier le moins cher. Un eval qui a besoin d'un modele frontier pour voir une regression mesure le modele, pas nous.

Alternatives écartées

  • Faire tourner les evals a chaque push. Coute de l'argent a chaque commit et rend le hook pre-push dependant du reseau. La cadence hebdomadaire attrape les derives de modele et de passerelle, qui sont lentes ; les regressions de prompt sont attrapees par le declenchement manuel dans la PR qui touche au prompt.
  • Un service d'observabilite commercial. Repond a une autre question (que s'est-il passe en production ?) et ne bloque rien avant le merge.

Note datee du 2026-09-25 (le texte ci-dessus n'est pas reecrit)

Trois phrases de cet ADR ne decrivent plus le harnais. La decision (vitest, pas un framework) tient ; ce sont des details d'execution qui ont bouge.

  1. « Le sujet et le juge tournent tous deux sur le tier le moins cher. » Le jour meme, @Atlas Mini est passe de Haiku 4.5 a GPT 5.6 Luna (ATLAS_TIER_MODEL["atlas-mini"] = "mini"). Le sujet code en dur (modelId("haiku")) mesurait donc un modele qu'aucun tour client n'emploie : une regression de routage sur Luna passait tous les cas. Le sujet est desormais le modele qu'@Atlas Mini envoie, LU dans le catalogue a chaque course (evals/support/models.ts), et EVAL_SUBJECT_MODEL (input subject_model du workflow) en nomme un autre : une cle du catalogue, un tier @Atlas, ou un id Gateway brut provider/model pour un candidat pas encore au catalogue. Le juge reste Haiku, fixe : deux courses ne se comparent que si le correcteur est le meme.
  2. « Le prix est une constante locale et non une lecture de model-pricing.ts. » La constante etait le tarif de Haiku, appliquee a tout. Juste tant que le sujet etait Haiku, fausse des qu'il ne l'est plus : un plafond de $0.50 compte au tarif Haiku laisse un candidat a $10/$50 depenser dix fois plus. Chaque appel est maintenant compte au tarif wholesale du catalogue de SON modele (sujet et juge separes). Un id inconnu du catalogue n'a pas de prix verifie : il est compte au tarif le plus eleve du catalogue, entree et sortie, pour que l'erreur termine la course trop tot plutot que de depenser sans accord.
  3. « EVAL_USD_BUDGET borne la course entiere. » C'etait deja faux avant ce changement, et verifie le 2026-09-25 : vitest isole chaque fichier dans son propre worker, donc le compteur de depense repart de zero a chaque fichier (trois fichiers qui incrementent un compteur de module lisent chacun 1). Le plafond vaut par FICHIER, quatre fichiers peuvent atteindre quatre fois le budget. isolate: false dans vitest.evals.config.ts le partage sur la course (meme verification : 1, 2, 3). Non applique ici : ce fichier etait hors du perimetre du changement qui a trouve le defaut.