Harness оценки
laya.evals превращает размеченный датасет в воспроизводимую оценку, а базовую линию — в
бинарный шлюз (прошёл/не прошёл), так что изменение качества становится проверяемым diff,
а не ручной проверкой.
Математика метрик и парсер датасета — это чистый Python плюс numpy, они никогда не импортируют torch, поэтому работают без весов. Прогон датасета против чекпойнта требует сам чекпойнт и занимает обычное время его загрузки.
Быстрый старт
# 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 ... — то же самое через основной CLI, поэтому laya eval validate data.jsonl тоже
работает.
Коды выхода: 0 при успехе, 1 когда не проходит порог или допуск базовой линии, 2 при
ошибке использования. run печатает общие метрики и запрошенные срезы в stdout, а также
пишет полный отчёт и сводку в Markdown, когда заданы --json / --markdown.
Атрибуция ошибок shortlist
Для размеченного набора выборов высокой кардинальности laya.evals_shortlist.evaluate_shortlist
использует существующий путь predict_shortlist и обычный harness оценки. Он отвечает на два
отдельных вопроса: сохранил ли поиск эталонную метку и выбрал ли её Laya, когда она была на месте?
Это opt-in Python API для меток choice; обычные отчёты laya-evals run не меняются.
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"])
Используйте ту же функцию эмбеддинга и тот же чекпойнт, что и измеряемое развёртывание. Оба
идентификатора задаёт вызывающий, и они должны называть неизменяемые ревизии; отчёт не может
вывести веса за произвольным callable. dataset_path записывает SHA256 файла рядом с существующим
отпечатком вопросов. Каждый случай хранит фактические метки shortlist и одно из значений
correct, retrieval_miss или decision_miss. shortlist_recall_at_k — это доля сохранённых
эталонных меток. shortlist_accuracy_on_recalled — правильные решения, делённые на сохранённые
случаи; он опускается, когда ни один случай не сохранён. Существующий choice_accuracy остаётся
сквозной точностью по всем случаям, включая промахи поиска. Метрики shortlist появляются в тех же
срезах по языку, модели, вопросу и тегу. Задержка запроса включает эмбеддинг и вызов решения;
отчёт не выделяет тайминги отдельных этапов. При k >= n исходный вопрос проходит как есть, и
полнота поиска равна 1 без вызова embedder.
Это не воспроизводит результаты BANKING77 из issue #102: эти числа зависят от его датасета, чекпойнта и би-энкодера. Это API делает такой же диагноз воспроизводимым на собственном размеченном наборе вызывающего.
Оценка экспорта ONNX
run --onnx PATH оценивает экспортированную модель ONNX через ONNXAgent вместо Router из torch,
поэтому развёртывание ONNX (включая копию INT8 из scripts/export_onnx.py --quantize)
проходит через те же пороги и базовые линии, что и путь 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 называет чекпойнт, из которого пришёл экспорт — id Hub или локальный путь, а не короткое
имя Router вроде english, поскольку на этом пути Router нет (по умолчанию
convaiinnovations/laya). Оттуда загружаются его конфигурация и токенизатор. Агент обслуживает один
чекпойнт, поэтому строка датасета, чьё поле model называет другой, падает с понятной ошибкой, а не
получает молча ответ не от той модели; --device не применяется. --batch-size использует
батч-API агента, когда тот его имеет, и иначе откатывается к одному вызову на состояние;
--sort-by-length переадресуется в этот батч-API; в резервном варианте на состояние нет группы,
которую можно переупорядочить. Передайте --calibration PATH, чтобы загрузить настроенную карту
калибровки в ONNXAgent, так что шлюзы калибровки вроде --max-ece оценивают по калиброванным
вероятностям. Блок config отчёта записывает путь onnx и путь calibration (когда он задан).
Измерено на research/evals/fixture.jsonl (12 размеченных строк, английский чекпойнт, CPU):
| runner | choice_acc | noul_acc | score_mae | ece | mean_conf | p50 ms |
|---|---|---|---|---|---|---|
| torch Router | 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 |
Экспорт fp32 точно воспроизводит числа torch, а квантованная копия сдвигает score_mae
на 0.009 и ece на 0.006 — как раз такой дрейф и должен ловить compare --tolerance.
Формат датасета
Один JSON-объект на строку (JSONL). Пустые строки и строки, начинающиеся с #, игнорируются.
| поле | обязательно | значение |
|---|---|---|
state |
да | текст, письмо, тикет или JSON-документ, по которому принимается решение |
questions |
да | словарь вопроса Laya, ровно как его принимает Router.predict |
expected |
да | эталонные значения с ключом по id вопроса: метка для choice, число для score, true/false для noul |
tags |
нет | строки для разбиения на срезы |
language |
нет | код для разбиения на срезы |
model |
нет | принудительно задаёт чекпойнт для этой строки; --model его переопределяет. Строка, которая ничего не задаёт принудительно, помечается тем чекпойнтом, которым ответил Router |
В research/evals/dataset.template.jsonl есть пример с комментариями.
Метрики
Каждая метрика вычисляется на каждый ответ, где применима, и агрегируется по датасету:
| метрика | применяется к | значение |
|---|---|---|
choice_accuracy |
choice |
доля, у которой выбранная метка совпала |
noul_accuracy |
noul |
доля, у которой булево значение (вероятность >= 0.5) совпало |
score_mae |
score |
средняя абсолютная ошибка |
score_within_<tol> |
score |
доля в пределах абсолютного допуска |
ece |
любой ответ с уверенностью | ожидаемая ошибка калибровки, 15 корзин, вычисляется по answer["answer_confidence"] — калиброванной вероятности, которую Laya сообщает для всех типов ответов |
brier |
любой ответ с уверенностью и известной меткой | Brier score уверенности как P(correct), mean((confidence - correct)**2); меньше — лучше |
aurc |
любой ответ с уверенностью и известной меткой | площадь под кривой риск–покрытие: одно значение риска на каждый отдельный уровень уверенности, каждое взвешено ответами, которые этот уровень покрывает; меньше — лучше, и поощряет уверенность, которая ранжирует верное от неверного, а не просто откалибрована |
selective_accuracy@50, selective_accuracy@80 |
любой ответ с уверенностью и известной меткой | точность по ответам, которые принимает порог уверенности в точке покрытия 50% / 80% – что покупает воздержание от наименее уверенного хвоста. Порог не может разбить группу равных уверенностей, поэтому это может покрыть больше названной доли; см. отсечения по покрытию |
mean_confidence |
любой ответ с уверенностью | среднее сообщённое answer["answer_confidence"] |
latency_p50_ms, latency_p95_ms |
на запрос | фактическое время ожидания каждого запроса, справочно — см. батчинг |
cost_per_decision_p50_ms, cost_per_decision_p95_ms |
на решение | фактическое время вызова, делённое на число несомых строк, справочно |
Отсечения по покрытию и равные уверенности
Обе метрики покрытия отсекают по порогу уверенности, а порог принимает каждый ответ на его
собственной уверенности. Поэтому отсечение никогда не разбивает группу ответов с одинаковой
уверенностью: когда coverage * n попадает внутрь такой группы, принимается каждый её член.
Число ответов за показателем — это, следовательно, верхняя граница группы, а не названная доля —
selective_accuracy@50 по срезу, где все уверенности равны, есть точность самого этого среза, а
не лучшая его половина. Счётчик, который печатает шлюз (n= в сообщении о падении правила), —
это размер среза, а не принятый размер, поэтому очень широкая группа не видна из одного сообщения.
Совпадения — это норма, а не угол: настроенная температура может оставить бакет, сообщающий
точечную массу, что laya.common.answer_confidence фиксирует у поставляемого choice:11+, а
реальный чекпойнт дал группу из шести строк ровно на 1.0 из двенадцати ответов. Отсечение по
индексу строки вместо этого заставило обе метрики зависеть от порядка, в котором пришёл датасет, —
одни и те же строки, перемешанные, двигали selective_accuracy@50 между 0.000 и 1.000.
aurc интегрирует одно значение риска на каждый отдельный уровень, взвешенное ответами, которые
этот уровень покрывает, поэтому остаётся площадью под кривой риск–покрытие, а не средним
неравномерно больших точек.
Два следствия, которые стоит учесть заранее:
- Число может сдвинуться в любую сторону, сильнее, чем мог бы дать переупорядочивание. Там,
где группа пересекает отсечение, чтение по порогу отличается от любого чтения по индексу строки
тех же данных: измерено на 400,001 датасете формы совпадений, до 0.500 для
selective_accuracy@50и 0.351 дляaurc. На форме из двенадцати ответов выше – шесть верных, все с уверенностью 1.0 –aurcсдвигается на 0.327 (с 0.173 до 0.500). Шлюз, который проходил, может упасть, а который падал — пройти; прежний вердикт зависел от порядка строк, в том числе для абсолютного пределаminилиmax, который ничто не отвергает, потому что он читает один прогон. - Перегенерируйте закоммиченные базовые линии.
config.coverage_metric_definitionзаписывает, какое определение произвело отчёт. Сравнение метрики покрытия между двумя определениями отвергается на обоих шлюзах, вычитающих базовую линию –--baseline --tolerance(EvalReport.compare) и относительном правиле (max_drop/max_increase) при--gate-policy– и при устаревании любой из сторон, а не только базовой линии: кандидат, произведённый более старымlaya, несёт артефакт порядка строк, который может читаться лучше истины, поэтому сравнение его с корректно перегенерированной базовой линией пропустило бы регрессию, которую корректно посчитанный отчёт отвергает. Без этого отказа устаревшая базовая линия скрывает реальную регрессию: срез, записанный как 0.033 по старому определению, читается как 0.517 по этому, поэтому кандидат, который честно упал на 0.217, прошёл быmax_drop0.05.eceиbrierне отсекают и остаются сопоставимыми.
Когда совпадений в данных нет, на каждый ответ приходится один уровень, и обе метрики — ровно то, чем всегда были, – побитово идентичны, а не просто близки.
Добавьте ScoreWithin(0.25) в список оценщиков, чтобы получить метрику допуска; набор по
умолчанию — choice_accuracy, noul_accuracy, score_mae, mean_confidence плюс ece. Из CLI то же
самое — один флаг: laya-evals run data.jsonl --score-within 0.25 сообщает score_within_0.25
рядом со значениями по умолчанию, а флаг повторяется, поэтому --score-within 0.25 --score-within 0.5
сообщает обе.
Метрике допуска нужен ответ score с числовой меткой, поэтому на датасете без такого она не имеет
значения: run называет метрику, которую не смог вычислить, вместо того чтобы опубликовать
молчаливый ноль, а шлюз --min / --max, называющий эту метрику, падает как отсутствующая. Запрошенные
для прогона допуски записываются в блок config отчёта, поэтому проверенная базовая линия говорит,
каких столбцов она ждёт.
Батчинг и тайминги
--batch-size N оценивает до N последовательных строк, разделяющих чекпойнт и схему вопросов, за один
вызов. Обе метрики времени происходят из одних и тех же измерений и отвечают на разные вопросы:
каждая строка батча возвращается тогда же, когда и батч, поэтому её latency — это весь вызов, а её
cost_per_decision — 1/N от него. Поэтому батчинг повышает latency_* и понижает
cost_per_decision_* на неизменном наборе решений, а --max latency_p50_ms=... спрашивает, обслуживались
ли запросы быстро, а не был ли прогон дешёвым. Без --batch-size они совпадают.
compare игнорирует любую метрику *_ms, если её не называет допуск, поэтому они никогда не заваливают
базовую линию из-за шума таймингов. Что harness реально сделал — запрошенный размер батча, форма
раннера, к которой он свёлся, сколько строк разделили вызов и самый крупный фрагмент —
записывается в config.timing отчёта, потому что один флаг не говорит, пакетировалось ли
что-нибудь. Эти счётчики фиксируют выданные вызовы, а не вернувшиеся: при
laya-evals run --on-error skip фрагмент, чей вызов возбудил исключение, всё равно учитывается в
rows_grouped и max_chunk, рядом со своими записями в config.errored. По умолчанию —
--on-error fail: он возбуждает исключение заново, а не публикует отчёт, метрики которого
охватывают только вернувшиеся вызовы. Две метрики *_ms считают только вернувшиеся вызовы,
поэтому упавший вызов никогда не вносит задержку, которую он не измерял.
Группировка строк внутри батча
--sort-by-length группирует строки похожего размера в один прямой проход, поэтому каждый
проход дополняется до более короткого максимума, а не до самой длинной строки в нём. Это форма
вызовов, а не их ответы: результаты возвращаются в том же порядке и оцениваются одинаково — вот
почему research/ может сообщать 2.15x на 10,000 тикетах, не меняя ни одного решения.
Для переупорядочивания нужно больше одного прохода, поэтому это действует только при --batch-size N
меньше числа строк, которые группирует прогон. config.timing разделяет два утверждения:
sort_by_length — то, что сказала командная строка, sort_by_length_sent — то, что дошло до раннера.
Прогон без --batch-size просит то, чего не может произойти, и говорит об этом через
sent: false; раннер, чей predict_batch старше этой ручки, оценивается без сортировки, а не
возбуждает TypeError на середине долгого прогона.
Шлюз воздержания при пороге
--min-confidence T передаёт opt-in порог воздержания ядра (#361) в каждый вызов, который делает
прогон, поэтому Router и ONNXAgent помечают ответы, чья answer_confidence ниже T, как
low_confidence: True до того, как их увидит harness. В отличие от группировки, это меняет ответы,
которые оцениваются: один и тот же прогон при T=0 и T=0.7 — это другой эксперимент, а
развёртка precision@coverage — это серия таких, а не одна дрейфующая базовая линия.
Допустимый диапазон — это laya.confidence.check_min_confidence ядра – [0.0, 1.0], конечный,
не bool – а не копия здесь, поэтому значение, которое отверг бы сам шлюз, падает как ошибка
использования (код 2) до загрузки любого чекпойнта. 0.0 — законный запрос: это контрольное
плечо развёртки precision@coverage, и проверка, которая его отбросила бы, скрыла бы собственный
пол этой развёртки.
Раннер, чей predict или (для батчевого прогона) чей predict_batch старше этого шлюза,
отвергается с именованной EvalError, а не оценивается без порога. Молча отбросить
контрольную точку оценки — это тот класс лжи, ради предотвращения которого существует этот
harness: отчёт опубликовал бы число precision@coverage для политики, которая никогда не
запускалась. config.timing записывает и запрос, и факт: min_confidence — запрошенный порог,
min_confidence_sent говорит, нёс ли его фактически хоть один вызов этого прогона.
Срезы
compare и run сообщают общие числа и, при --slice language|model|qid|tag, те же
метрики по каждому значению среза, поэтому регрессия в одном языке или одном вопросе видна без
чтения агрегата. Срез model содержит чекпойнт, ответивший на каждую строку: собственный
выбор Router на каждый запрос или model раннера для раннера, который не маршрутизирует.
Opt-in шлюзы срезов
Общий шлюз базовой линии может пройти, пока меньший срез по языку или вопросу регрессирует. Чтобы
сделать один проверенный срез требованием CI, сохраните политику JSON, например 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
Каждое правило выбирает ровно одно значение language, model, qid или tag и называет
метрику ровно так, как она появляется в отчёте по срезу. У него положительный min_count и ровно
один предел: min или max проверяет значение кандидата; max_drop допускает не более такого
уменьшения относительно базовой линии; max_increase допускает не более такого увеличения.
Последние два требуют --baseline. Счётчик — это число оценённых ответов для этой метрики в
выбранном срезе, в обоих отчётах для относительного правила. Для ece это число ответов с
конечной уверенностью и булевым значением correct. Отсутствующий срез или метрика, слишком мало
оценённых ответов или пропущенные/ошибочные случаи — всё это проваливает opt-in шлюз. Относительные
правила также требуют, чтобы оба отчёта несли совпадающие идентичности прогона, чтобы отсутствующее
доказательство не могло выглядеть как прохождение. Измеренная регрессия сообщает срез, метрику,
счётчики, значения и предел. Неверный синтаксис политики завершается кодом 2 до загрузки
чекпойнта; провал качества завершается кодом 1. Политика записывается в config.gate_policy отчёта
run --json. compare --gate-policy применяет политику, заданную в этой командной строке, к
сохранённым измерениям. Если она отличается от записанной в отчёте политики, compare говорит об
этом; явная повторная проверка при новой политике не меняет политику, при которой был сделан
исходный прогон.
Обычное общее сравнение по-прежнему применяется, включая его допуск и поведение устаревшей
базовой линии. Без --gate-policy отчётность и сравнение срезов ведут себя как раньше.
Идентичность прогона
run записывает то, что измерил, в блок config отчёта, поэтому артефакт, который читает проверяющий,
проверяем сам по себе:
| ключ | значение |
|---|---|
schema |
форма отчёта, laya-evals-report/1, чтобы потребитель мог отвергнуть тот, который не умеет читать |
dataset |
путь, как он был введён — имя, а не хеш |
dataset_sha256 |
sha256 байтов датасета, которые были разобраны |
questions_sha256 |
отпечаток схемы вопросов: id, тип, instructions и criteria каждого вопроса, по всему датасету |
laya_version |
laya, вычислившая числа |
coverage_metric_definition |
какое определение aurc / selective_accuracy@* произвело этот отчёт (см. отсечения по покрытию). Относительное правило шлюза по любому из них отвергает базовую линию, записанную при другом определении, вместо вычитания чисел, которые не означают одно и то же |
thresholds |
шлюз, который применил этот прогон: min, max и baseline_tolerance |
gate_policy |
необязательная политика шлюзов срезов, применённая run --gate-policy |
revisions |
коммит, из которого загружался каждый ответивший чекпойнт (см. ниже) |
dataset — это путь, а путь не является идентичностью: датасет можно отредактировать на месте,
переместить или перекачать под тем же именем, а кэш CI может выдать двум прогонам одно и то же имя
файла и разные байты. questions_sha256 покрывает то, что было спрошено, а не сколько строк
было, поэтому добавление состояний к неизменному набору вопросов не трогает отпечаток —
dataset_sha256 всё равно меняется, а добавление строки — это изменение данных, а не вопроса.
Он покрывает и instructions, потому что текст инструкции — это prompt. build_sequence рендерит
"<type> question: <instructions>" в токенизированный head, Agent отвергает вопрос без него
(«add the text the model should answer»), и собственная идентичность вопроса Laya уже его
учитывает: Router._question_schema и батчинг-группировка этого harness’а обе опираются на весь словарь
вопросов, а tests/test_router_batch.py фиксирует, что одна лишь переформулировка instructions
переводит строку в её собственную группу батча. Так что переформулированная инструкция всё ещё
сравнивается как равная базовой линии? Нет — и в этом суть. «Judge whether a refund is justified»
и «Be conservative and only approve explicit refund requests» задают разные вопросы, и шлюз метрик
может заметить это, только когда разница случайно сдвинет число дальше названного вами допуска.
Название варианта choice — тот же аргумент: criteria — это пространство решений, а
метаморфические проверки в research/eval/metamorphic.py существуют потому, что переименование
метки переворачивает ответы.
В тексте инструкции ничего не нормализуется, кроме единственного шага, который применяет сам
движок: нестроковое instructions хешируется как json.dumps(ins, ensure_ascii=False), что совпадает
с Agent._to_internal. Поэтому и пробелы, и формулировка имеют значение, а переформулировка,
которую человек считает редакторской правкой, трактуется как новый эксперимент. Это честное
поведение по умолчанию — альтернатива — эвристика схожести, стоящая между прогоном и его
базовой линией, а такой нет ни в одной из общеупотребительных систем оценки.
Ничего, несущего время, не записывается, поэтому отчёт остаётся побайтово воспроизводимым для фиксированного раннера.
REPORT_SCHEMA, questions_fingerprint(dataset) и file_fingerprint(path) публичны, поэтому
вызывающий, работающий с laya.evals.evaluate напрямую, получает ту же идентичность, что и прогон
через CLI.
Базовая линия и шлюз CI
- Держите датасет, отчёт базовой линии (вывод
--json, который вы проверили) и допуски вместе, закоммиченными, чтобы изменение было проверяемым diff.--tolerance METRIC=VALUE— максимальный абсолютный дрейф, допустимый для этой метрики. laya-evals run ... --baseline baseline.json --tolerance ...завершается с ненулевым кодом при дрейфе, поэтому без изменений встраивается в CI.laya.evals.EvalReport.compareиassert_regressionпредоставляют ту же логику для тестов.
Шлюз метрик отвечает на вопрос «сдвинулись ли числа». Он не может ответить «были ли это те же
числа», потому что compare читает overall и только overall — поэтому базовая линия,
записанная против одного датасета, пропустила бы кандидата, оценённого на другом, при идентичной
арифметике. EvalReport.comparable_to закрывает это: он сравнивает schema, dataset_sha256 и
questions_sha256, а run --baseline и compare всё равно печатают все дельты, затем падают
с ненулевым кодом выхода, называя ключ и оба значения:
FAIL: baseline is not comparable: dataset_sha256 (dataset bytes): baseline is <sha>, this run is <sha>
Отсутствующий с любой стороны ключ — это неизвестно, а не конфликт, поэтому каждый отчёт,
написанный до появления идентичности, продолжает сравниваться ровно как раньше. Это включает
базовую линию запланированного шлюза ниже, которая приходит из research/eval/ и вообще не
имеет config.schema.
Это используют две поверхности CI:
- задание без весов в
.github/workflows/ci.ymlзапускаетtests/test_evals.pyиtests/test_evals_api.py, поэтому математика метрик, парсинг датасета и CLI покрываются на каждом PR без скачивания чекпойнта; .github/workflows/evals.ymlзапускается еженедельно, перед релизом и по требованию: он оценивает английский чекпойнт на английском наборе MASSIVE и сравнивает сresearch/results/eval_english_51_languages.jsonс допусками изresearch/evals/thresholds.json. Он загружает отчёт как артефакт и не блокирует PR.
Harness детерминирован для фиксированной ревизии чекпойнта, поэтому отчёт воспроизводим. run
записывает датасет, модель и устройство, плюс факты о таймингах
прогона, в блок config отчёта, и revisions: коммит, из которого фактически загружался
каждый ответивший чекпойнт. --revision <SHA> фиксирует этот коммит для каждого загружаемого
прогоном чекпойнта, а --revision english=<SHA> фиксирует один чекпойнт (повторяемо) — это
форма, нужная прогону с автоматической маршрутизацией, поскольку три чекпойнта — это три
репозитория, и один коммит не может существовать во всех них. Если не фиксировать, прогон берёт
ветку чекпойнта по умолчанию, а отчёт всё равно говорит, какой коммит ответил, поэтому дрейф
базовой линии можно отнести к весам или к коду. laya/revisions.py публикует проверенные SHA
коммитов в PINNED_REVISIONS для вызывающих, которые хотят этим воспользоваться. С --onnx
применяется только голый --revision <SHA> — к загрузке конфигурации и токенизатора.
Добавление реального размеченного набора
Положите JSONL в research/evals/ и проверенную базовую линию рядом с ним, затем направьте на
оба рабочий процесс (или research/evals/check_regression.py). Формат тот же, что у fixture;
ничего в harness не знает о MASSIVE.