Documentation

Harnais d’évaluation

laya.evals transforme un jeu de données étiqueté en score reproductible, et une référence en porte réussite/échec, pour qu’un changement de qualité soit un diff relisible plutôt qu’une vérification à la main.

Le calcul des métriques et le parseur de jeu de données sont du Python pur plus numpy et n’importent jamais torch, donc ils tournent sans poids. Exécuter un jeu de données contre un checkpoint nécessite le checkpoint et prend son temps de chargement normal.

Démarrage rapide

# check the format without a model
laya-evals validate research/evals/fixture.jsonl

# score a labelled set on one checkpoint, with thresholds and a baseline
laya-evals run data.jsonl --model english --device cpu \
    --min-accuracy 0.8 --max-ece 0.05 --score-within 0.25 --slice language \
    --json report.json --markdown report.md

# compare a saved report to a baseline
laya-evals compare report.json --baseline baseline.json --tolerance choice_accuracy=0.02

laya eval ... est la même chose via la CLI principale, donc laya eval validate data.jsonl fonctionne aussi.

Codes de sortie : 0 en cas de succès, 1 quand un seuil ou une tolérance de référence échoue, 2 en cas d’erreur d’usage. run imprime les métriques globales et les tranches demandées sur stdout, et écrit le rapport complet et un résumé Markdown quand --json / --markdown sont donnés.

Attribuer les erreurs de présélection

Pour un ensemble choice étiqueté à cardinalité élevée, laya.evals_shortlist.evaluate_shortlist utilise le chemin predict_shortlist existant et le harnais d’évaluation habituel. Il répond à deux questions distinctes : la récupération a-t-elle conservé le libellé de référence, et Laya l’a-t-il choisi quand il était présent ? C’est une API Python opt-in pour les libellés choice ; les rapports ordinaires de laya-evals run sont inchangés.

import laya
from laya.evals import Dataset
from laya.evals_shortlist import evaluate_shortlist
from laya.shortlist import embed_fn_from_agent

agent = laya.load()
dataset_path = "intents.jsonl"
dataset = Dataset.from_jsonl(dataset_path)
report = evaluate_shortlist(
    agent, dataset, embed_fn_from_agent(agent), k=20,
    checkpoint_id="my-checkpoint@revision", embedder_id="my-encoder@revision",
    dataset_path=dataset_path,
)
print(report.overall)
print(report.cases[0]["shortlist_status"])

Utilise la même fonction d’embedding et le même checkpoint que le déploiement mesuré. Les deux identifiants sont fournis par l’appelant et doivent nommer des révisions immuables ; le rapport ne peut pas déduire les poids derrière un callable arbitraire. dataset_path enregistre le SHA256 du fichier à côté de l’empreinte de questions existante. Chaque cas conserve les libellés réels de la présélection et l’un de correct, retrieval_miss ou decision_miss. shortlist_recall_at_k est la fraction de libellés de référence conservés. shortlist_accuracy_on_recalled est le nombre de décisions correctes divisé par le nombre de cas conservés ; il est omis quand aucun ne l’a été. Le choice_accuracy existant reste l’exactitude de bout en bout sur tous les cas, y compris les échecs de récupération. Les métriques de présélection apparaissent dans les mêmes tranches langue, modèle, question et tag. La latence de requête inclut l’embedding et l’appel de décision ; le rapport n’isole pas les temps par étape. Avec k >= n, la question d’origine passe telle quelle et le rappel de récupération vaut 1 sans appeler l’embedder.

Cela ne reproduit pas les résultats BANKING77 de l’issue #102 : ces nombres dépendent de son jeu de données, de son checkpoint et de son bi-encoder. Cette API rend le même type de diagnostic reproductible sur le propre jeu étiqueté d’un appelant.

Évaluer un export ONNX

run --onnx PATH évalue un modèle ONNX exporté via ONNXAgent au lieu du Router torch, donc un déploiement ONNX (y compris une copie INT8 de scripts/export_onnx.py --quantize) passe par les mêmes seuils et références que le chemin torch :

python scripts/export_onnx.py --model convaiinnovations/laya --output laya.onnx --quantize
laya-evals run data.jsonl --onnx laya.int8.onnx --max-ece 0.05

--model nomme le checkpoint d’où vient l’export — un id Hub ou un chemin local, pas un nom court de Router comme english, puisqu’il n’y a pas de Router sur ce chemin (défaut convaiinnovations/laya). Sa config et son tokenizer sont chargés depuis là. L’agent sert un seul checkpoint, donc une ligne de jeu de données dont le champ model en nomme un autre échoue avec une erreur claire plutôt que d’être silencieusement répondue par le mauvais modèle ; --device ne s’applique pas. --batch-size utilise l’API de lot de l’agent quand il en a une et retombe sur un appel par état sinon ; --sort-by-length est transmis à cette API de lot ; le fallback par état n’a aucun groupe à réordonner. Passe --calibration PATH pour charger une carte de calibration ajustée sur ONNXAgent, afin que les portes de calibration comme --max-ece s’évaluent contre des probabilités calibrées. Le bloc config du rapport enregistre le chemin onnx et le chemin calibration (quand il est défini).

Mesuré sur research/evals/fixture.jsonl (12 lignes étiquetées, checkpoint anglais, CPU) :

runner choice_acc noul_acc score_mae ece mean_conf p50 ms
Router torch 0.75 1.00 1.3418 0.1596 0.7304 116.8
--onnx fp32 0.75 1.00 1.3418 0.1596 0.7304 66.3
--onnx int8 0.75 1.00 1.3512 0.1658 0.7304 46.3

L’export fp32 reproduit exactement les nombres torch, et la copie quantifiée déplace score_mae de 0.009 et ece de 0.006 — le genre de dérive que compare --tolerance est censé gater.

Format du jeu de données

Un objet JSON par ligne (JSONL). Les lignes vides et les lignes commençant par # sont ignorées.

champ requis signification
state oui texte, email, ticket ou document JSON sur lequel décider
questions oui un dict de questions Laya, exactement comme Router.predict l’accepte
expected oui vérité terrain indexée par id de question : un libellé pour choice, un nombre pour score, true/false pour noul
tags non chaînes par lesquelles découper
language non un code par lequel découper
model non force un checkpoint pour cette ligne ; --model le surcharge. Une ligne qui ne force rien est étiquetée avec le checkpoint avec lequel le Router a répondu

research/evals/dataset.template.jsonl a un exemple commenté.

Métriques

Chaque métrique est calculée par réponse là où elle s’applique et agrégée sur le jeu de données :

métrique s’applique à signification
choice_accuracy choice fraction dont le libellé choisi correspond
noul_accuracy noul fraction dont le booléen (probabilité >= 0.5) correspond
score_mae score erreur absolue moyenne
score_within_<tol> score fraction dans une tolérance absolue
ece toute réponse avec une confiance erreur de calibration attendue, 15 bacs, calculée sur answer["answer_confidence"], la probabilité calibrée que Laya rapporte sur chaque type de réponse
brier toute réponse avec une confiance et un libellé connu score de Brier de la confiance comme P(correct), mean((confidence - correct)**2) ; plus bas vaut mieux
aurc toute réponse avec une confiance et un libellé connu aire sous la courbe risque–couverture : une valeur de risque par niveau de confiance distinct, chacune pondérée par les réponses que ce niveau couvre ; plus bas vaut mieux, et récompense une confiance qui classe le correct du faux plutôt que d’être seulement calibrée
selective_accuracy@50, selective_accuracy@80 toute réponse avec une confiance et un libellé connu exactitude sur les réponses qu’un seuil de confiance au point de couverture 50 % / 80 % accepte – ce que rapporte le fait de s’abstenir sur la queue la moins confiante. Un seuil ne peut pas scinder un groupe de confiances égales, donc cela peut couvrir plus que la fraction nommée ; voir coupures de couverture
mean_confidence toute réponse avec une confiance moyenne de answer["answer_confidence"] rapportée
latency_p50_ms, latency_p95_ms par requête temps mural que chaque requête a attendu, informatif – voir traitement par lots
cost_per_decision_p50_ms, cost_per_decision_p95_ms par décision le temps mural d’un appel divisé par les lignes qu’il portait, informatif

Coupures de couverture et égalités

Les deux métriques de couverture découpent sur un seuil de confiance, et un seuil accepte chaque réponse à sa propre confiance. Une coupure ne scinde donc jamais un groupe de réponses qui partagent la même : quand coverage * n tombe à l’intérieur d’un tel groupe, chaque membre du groupe est accepté. Le nombre de réponses derrière le chiffre est donc le bord supérieur du groupe plutôt que la fraction nommée – selective_accuracy@50 sur une tranche dont les confiances sont toutes égales est l’exactitude propre de cette tranche, pas sa meilleure moitié. Le compte qu’imprime la porte (n= dans le message d’échec d’une règle) est la taille de la tranche, pas la taille acceptée, donc un groupe très large n’est pas visible depuis le message seul.

Les égalités sont le cas normal plutôt qu’un cas limite : une température ajustée peut laisser un bac rapporter une masse ponctuelle, ce que laya.common.answer_confidence enregistre du choice:11+ livré, et un vrai checkpoint a produit un groupe de six lignes à exactement 1.0 sur douze réponses. Découper à un index de ligne à la place faisait dépendre les deux métriques de l’ordre d’arrivée du jeu de données – les mêmes lignes, mélangées, déplaçaient selective_accuracy@50 entre 0.000 et 1.000.

aurc intègre une valeur de risque par niveau distinct, pondérée par les réponses que ce niveau couvre, donc elle reste une aire sous la courbe risque–couverture plutôt qu’une moyenne de points de tailles inégales.

Deux conséquences à anticiper :

  • Un nombre peut bouger dans un sens ou dans l’autre, et plus qu’un simple réordonnancement ne le pourrait. Là où un groupe chevauche la coupure, la lecture par seuil diffère de toute lecture par index de ligne des mêmes données : mesuré sur 400,001 jeux de données en forme d’égalités, jusqu’à 0.500 pour selective_accuracy@50 et 0.351 pour aurc. Sur la forme à douze réponses ci-dessus – six correctes, toutes à une confiance de 1.0 – aurc bouge de 0.327 (0.173 à 0.500). Une porte qui réussissait peut échouer, et une qui échouait peut réussir ; le verdict précédent dépendait de l’ordre des lignes, y compris pour une limite absolue min ou max, que rien ne refuse parce qu’elle lit une seule exécution.
  • Régénère les références commitées. config.coverage_metric_definition enregistre quelle définition a produit un rapport. Comparer une métrique de couverture entre deux définitions est refusé sur les deux portes qui soustraient une référence – --baseline --tolerance (EvalReport.compare) et une règle relative (max_drop / max_increase) sous --gate-policy – et dès que l’un ou l’autre côté est périmé, pas seulement la référence : un candidat produit par un laya plus ancien porte un artefact d’ordre des lignes qui peut lire mieux que la vérité, donc le gater contre une référence correctement régénérée laisserait passer une régression que le rapport correctement noté fait échouer. Sans ce refus, une référence périmée masque une vraie régression : une tranche enregistrée à 0.033 sous l’ancienne définition lit 0.517 sous celle-ci, donc un candidat qui a réellement chuté de 0.217 franchirait un max_drop de 0.05. ece et brier ne découpent pas et restent comparables.

Sans égalités dans les données, il y a un niveau par réponse, et les deux métriques sont exactement ce qu’elles ont toujours été – identiques bit à bit, pas seulement proches.

Ajoute ScoreWithin(0.25) à la liste d’évaluateurs pour une métrique de tolérance ; l’ensemble par défaut est choice_accuracy, noul_accuracy, score_mae, mean_confidence, plus ece. Depuis la CLI, la même chose est un drapeau : laya-evals run data.jsonl --score-within 0.25 rapporte score_within_0.25 à côté des défauts, et le drapeau se répète, donc --score-within 0.25 --score-within 0.5 rapporte les deux.

Une métrique de tolérance a besoin d’une réponse score avec un libellé numérique, donc sur un jeu de données qui n’en a pas elle n’a pas de valeur : run nomme la métrique qu’il n’a pas pu calculer au lieu de publier un zéro silencieux, et une porte --min / --max nommant cette métrique échoue comme manquante. Les tolérances demandées à une exécution sont enregistrées dans le bloc config du rapport, donc une référence revue dit quelles colonnes elle attend.

Traitement par lots et timing

--batch-size N évalue jusqu’à N lignes consécutives qui partagent un checkpoint et un schéma de questions en un seul appel. Les deux métriques de timing viennent des mêmes mesures et répondent à des questions différentes : chaque ligne d’un lot revient quand le lot revient, donc sa latency est tout l’appel, tandis que son cost_per_decision en est 1/N. Le traitement par lots augmente donc latency_* et abaisse cost_per_decision_* sur un ensemble de décisions inchangé, et --max latency_p50_ms=... demande si les requêtes ont été servies vite, pas si l’exécution a été bon marché. Sans --batch-size, les deux s’accordent.

compare ignore toute métrique *_ms sauf si une tolérance la nomme, donc celles-ci ne font jamais échouer une référence sur du bruit de timing. Ce que le harnais a réellement fait – la taille de lot demandée, la forme de runner vers laquelle il a résolu, combien de lignes partageaient un appel, et le plus grand morceau – est enregistré dans config.timing du rapport, parce que le drapeau seul ne dit pas si quoi que ce soit a été mis en lot. Ces compteurs enregistrent les appels émis, pas les appels qui sont revenus : avec laya-evals run --on-error skip, un morceau dont l’appel a levé compte quand même dans rows_grouped et max_chunk, à côté de ses entrées dans config.errored. Le défaut est --on-error fail, qui relève l’erreur au lieu de publier un rapport dont les métriques ne couvrent que les appels revenus. Les deux métriques *_ms ne comptent que les appels qui sont revenus, donc un appel échoué ne contribue jamais une latence qu’il n’a pas mesurée.

Grouper les lignes dans un lot

--sort-by-length groupe les lignes de taille similaire dans la même passe avant, pour que chaque passe se remplisse jusqu’à un maximum plus court au lieu de la ligne la plus longue du lot. C’est la forme des appels, pas leurs réponses : les résultats reviennent dans le même ordre et obtiennent le même score, c’est pourquoi research/ peut rapporter 2.15x sur 10,000 tickets sans qu’aucune décision ne change.

Il faut plus d’une passe pour réordonner, donc cela ne prend effet qu’avec un --batch-size N inférieur au nombre de lignes que l’exécution groupe. config.timing tient les deux affirmations séparées : sort_by_length est ce que la ligne de commande a dit, sort_by_length_sent est ce qui a atteint le runner. Une exécution sans --batch-size demande quelque chose qui ne peut pas arriver, et le dit avec sent: false ; un runner dont le predict_batch est antérieur au réglage est évalué non trié plutôt que de lever TypeError au milieu d’une longue exécution.

La porte d’abstention à un seuil

--min-confidence T transmet le seuil d’abstention opt-in du cœur (#361) à chaque appel que fait l’exécution, donc Router et ONNXAgent marquent les réponses dont l’answer_confidence tombe sous T avec low_confidence: True avant que le harnais ne les voie. Contrairement au regroupement, cela change les réponses qui sont notées : la même exécution à T=0 et T=0.7 est une expérience différente, et un balayage precision@coverage est une série de celles-ci, pas une seule référence qui dérive.

La plage acceptée est celle de laya.confidence.check_min_confidence du cœur – [0.0, 1.0], finie, pas un bool – plutôt qu’une copie ici, donc une valeur que la porte elle-même rejetterait échoue comme erreur d’usage (sortie 2) avant qu’aucun checkpoint ne se charge. 0.0 est une demande légale : c’est le bras de contrôle d’un balayage precision@coverage, et une vérification qui l’écarterait masquerait le plancher propre du balayage.

Un runner dont le predict ou (pour une exécution par lots) dont le predict_batch est antérieur à la porte est refusé avec un EvalError nommé, pas noté sans le seuil. Laisser tomber silencieusement un contrôle de notation est le genre de mensonge que ce harnais existe pour empêcher : le rapport publierait un chiffre precision@coverage pour une politique qui n’a jamais tourné. config.timing enregistre à la fois la demande et le fait : min_confidence est le seuil qui a été demandé, min_confidence_sent dit si un appel de cette exécution l’a réellement porté.

Tranches

compare et run rapportent les nombres globaux et, pour --slice language|model|qid|tag, les mêmes métriques par valeur de tranche, donc une régression dans une langue ou une question est visible sans lire l’agrégat. La tranche model contient le checkpoint qui a répondu à chaque ligne : le choix du Router lui-même par requête, ou le model du runner pour un runner qui ne route pas.

Portes de tranche opt-in

La porte de référence globale peut réussir pendant qu’une tranche de langue ou de question plus petite régresse. Pour faire d’une tranche revue une exigence de CI, enregistre une politique JSON comme gates.json :

{
  "version": 1,
  "rules": [
    {"slice": {"language": "zh"}, "metric": "choice_accuracy",
     "min_count": 50, "max_drop": 0.05},
    {"slice": {"qid": "intent"}, "metric": "ece",
     "min_count": 50, "max": 0.10}
  ]
}
laya-evals run data.jsonl --baseline baseline.json --tolerance choice_accuracy=0.02 \
    --gate-policy gates.json --json report.json
laya-evals compare report.json --baseline baseline.json \
    --tolerance choice_accuracy=0.02 --gate-policy gates.json

Chaque règle sélectionne exactement une valeur language, model, qid ou tag et nomme la métrique exactement comme elle apparaît dans le rapport de tranche. Elle a un min_count positif et exactement une limite : min ou max vérifie la valeur du candidat ; max_drop autorise au plus cette baisse par rapport à la référence ; max_increase autorise au plus cette hausse. Les deux dernières exigent --baseline. Le compte est le nombre de réponses notées pour cette métrique dans la tranche sélectionnée, dans les deux rapports pour une règle relative. Pour ece, c’est le nombre de réponses avec une confiance finie et une valeur booléenne correct. Une tranche ou une métrique manquante, trop peu de réponses notées, ou des cas ignorés/en erreur font échouer la porte opt-in. Les règles relatives exigent aussi que les deux rapports portent des identités d’exécution correspondantes, pour qu’une preuve manquante ne puisse pas apparaître comme une réussite. Une régression mesurée rapporte la tranche, la métrique, les comptes, les valeurs et la limite. Une syntaxe de politique invalide sort en 2 avant qu’un checkpoint ne se charge ; un échec de qualité sort en 1. La politique est enregistrée dans config.gate_policy d’un rapport run --json. compare --gate-policy applique la politique fournie sur cette ligne de commande aux mesures enregistrées. Si elle diffère de la politique enregistrée dans le rapport, compare le dit ; une revérification explicite sous une nouvelle politique ne change pas la politique sous laquelle l’exécution d’origine a été faite.

La comparaison globale habituelle s’applique toujours, y compris sa tolérance et son comportement de référence héritée. Sans --gate-policy, les rapports de tranche et la comparaison se comportent comme avant.

Identité d’exécution

run enregistre ce qu’il a mesuré dans le bloc config du rapport, donc l’artefact que lit un relecteur est relisible en lui-même :

clé signification
schema la forme du rapport, laya-evals-report/1, pour qu’un consommateur puisse refuser un qu’il ne peut pas lire
dataset le chemin tel que saisi – un nom, pas un hash
dataset_sha256 le sha256 des octets du jeu de données qui ont été parsés
questions_sha256 une empreinte du schéma de questions : l’id, le type, instructions et criteria de chaque question, sur tout le jeu de données
laya_version le laya qui a calculé les nombres
coverage_metric_definition quelle définition de aurc / selective_accuracy@* a produit ce rapport (voir coupures de couverture). Une règle de porte relative sur l’une ou l’autre refuse une référence enregistrée sous une définition différente, plutôt que de soustraire des nombres qui ne veulent pas dire la même chose
gate_policy la politique optionnelle de portes de tranche appliquée par run --gate-policy
thresholds la porte appliquée par cette exécution : min, max et baseline_tolerance
revisions le commit depuis lequel chaque checkpoint qui a répondu a été chargé (voir ci-dessous)

dataset est un chemin, et un chemin n’est pas une identité : un jeu de données peut être modifié sur place, déplacé, ou retéléchargé sous le même nom, et un cache CI peut donner à deux exécutions le même nom de fichier et des octets différents. questions_sha256 couvre ce qui a été demandé plutôt que combien de lignes il y avait, donc ajouter des états à un ensemble de questions inchangé laisse l’empreinte tranquille – dataset_sha256 bouge quand même, et ajouter une ligne est un changement des données, pas de la question.

Il couvre instructions aussi, parce que le texte d’instruction est le prompt. build_sequence rend "<type> question: <instructions>" dans la tête tokenisée, Agent refuse une question sans instruction (« ajoute le texte auquel le modèle doit répondre »), et l’identité de question de Laya la compte déjà : Router._question_schema et le groupement par lots de ce harnais indexent tous deux sur le dict de questions entier, et tests/test_router_batch.py épingle qu’une reformulation des instructions seule déplace une ligne dans son propre groupe de lot. Donc une instruction reformulée se compare-t-elle quand même égale à une référence ? Non – et c’est le but. « Juge si un remboursement est justifié » et « Sois prudent et n’approuve que les demandes de remboursement explicites » posent des questions différentes, et la porte de métrique ne peut le remarquer que quand la différence déplace un nombre au-delà de la tolérance que tu as nommée. Nommer une option de choice relève du même argument : criteria est l’espace de décision, et les vérifications métamorphiques de research/eval/metamorphic.py existent parce que renommer un libellé inverse des réponses.

Rien dans le texte d’instruction n’est normalisé, sauf la seule étape que le moteur applique lui-même : un instructions non-chaîne est haché comme json.dumps(ins, ensure_ascii=False), comme Agent._to_internal. Donc les espaces et la formulation comptent tous les deux, et une reformulation qu’un humain considère comme une correction est traitée comme une nouvelle expérience. C’est le défaut honnête – l’alternative est une heuristique de similarité entre une exécution et sa référence, et aucun système d’évaluation courant n’en a.

Rien qui porte un horodatage n’est enregistré, donc un rapport reste reproductible octet pour octet pour un runner fixe.

REPORT_SCHEMA, questions_fingerprint(dataset) et file_fingerprint(path) sont publics, donc un appelant qui pilote directement laya.evals.evaluate obtient la même identité qu’une exécution CLI.

Référence et porte CI

  • Garde le jeu de données, un rapport de référence (sortie --json que tu as relue) et les tolérances ensemble, commités, pour qu’un changement soit un diff relisible. --tolerance METRIC=VALUE est la dérive absolue maximale autorisée pour cette métrique.
  • laya-evals run ... --baseline baseline.json --tolerance ... sort en non-zéro en cas de dérive, donc il s’intègre tel quel dans la CI. laya.evals.EvalReport.compare et assert_regression exposent la même logique pour les tests.

La porte de métrique répond à « les nombres ont-ils bougé ». Elle ne peut pas répondre à « étaient-ce les mêmes nombres », parce que compare lit overall et seulement overall – donc une référence enregistrée contre un jeu de données validerait un candidat évalué sur un autre, avec une arithmétique identique. EvalReport.comparable_to referme cela : il compare schema, dataset_sha256 et questions_sha256, et run --baseline et compare impriment quand même chaque delta, puis échouent avec un code de sortie non nul en nommant la clé et les deux valeurs :

FAIL: baseline is not comparable: dataset_sha256 (dataset bytes): baseline is <sha>, this run is <sha>

Une clé absente d’un côté est inconnue, pas un conflit, donc chaque rapport écrit avant que l’identité n’existe continue de se comparer exactement comme avant. Cela inclut la référence de la porte planifiée ci-dessous, qui vient de research/eval/ et n’a aucun config.schema.

Deux surfaces CI utilisent ceci :

  • un job sans poids dans .github/workflows/ci.yml exécute tests/test_evals.py et tests/test_evals_api.py, donc le calcul des métriques, le parsing de jeu de données et la CLI sont couverts à chaque PR sans télécharger de checkpoint ;
  • .github/workflows/evals.yml tourne chaque semaine, avant une release et à la demande : il évalue le checkpoint anglais sur la suite MASSIVE anglaise et compare à research/results/eval_english_51_languages.json avec les tolérances de research/evals/thresholds.json. Il téléverse le rapport comme artefact et ne bloque pas une PR.

Le harnais est déterministe pour une révision de checkpoint fixe, donc un rapport est reproductible. run enregistre le jeu de données, le modèle et l’appareil, plus les faits de timing de l’exécution, dans le bloc config du rapport, et revisions : le commit depuis lequel chaque checkpoint qui a répondu a été réellement chargé. --revision <SHA> épingle ce commit pour chaque checkpoint que l’exécution charge, et --revision english=<SHA> épingle un checkpoint (répétable) — ce qui est la forme que veut une exécution en auto-routage, puisque les trois checkpoints sont trois dépôts et qu’un commit ne peut pas exister dans tous. Laissé non épinglé, l’exécution prend la branche par défaut du checkpoint et le rapport dit quand même quel commit a répondu, donc une dérive de référence peut être attribuée aux poids ou au code. laya/revisions.py publie des SHA de commit revus dans PINNED_REVISIONS pour les appelants qui veulent s’y inscrire. Avec --onnx, seul un --revision <SHA> nu s’applique, au téléchargement de la config et du tokenizer.

Ajouter le vrai jeu étiqueté

Dépose un JSONL dans research/evals/ et une référence revue à côté, puis pointe un workflow (ou research/evals/check_regression.py) vers les deux. Le format est le même que celui du fixture ; rien dans le harnais ne connaît MASSIVE.