Merge pull request 'feat(diarization): завершить Ryzen-этап CPU-эксперимента' (#44) from issue-42-ryzen-cpu-experiment into master
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
commit
312153f4bc
3 files changed
+782
-19
No files matched your search
@@ -0,0 +1,318 @@
|
|||||||
|
# CPU-диаризация: Ryzen-этап эксперимента
|
||||||
|
|
||||||
|
**Дата:** 2026-08-31
|
||||||
|
**Статус:** `new map`; C2 прошла проверки качества и памяти, но не достигла
|
||||||
|
итогового порога ускорения 30%, а более быстрые ветви Intel уперлись в качество.
|
||||||
|
Production-профиль не менялся.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
На AMD Ryzen 7 8845H выполнен этап 5 эксперимента из
|
||||||
|
[спецификации](../specs/2026-08-30-cpu-diarization-experiment.md) по единственному
|
||||||
|
кандидату C2 из [Intel-этапа](2026-08-31-cpu-diarization-intel.md). Этап 6 не
|
||||||
|
запускали: победителя нет. C2 сохраняет `window_shift_ratio=0.1`, Pyannote
|
||||||
|
Segmentation 3.0 FP32, WeSpeaker ResNet34 LM FP32 и `FastClustering(0.89)`.
|
||||||
|
Embedding-вызовы выполняют два outer worker через одну общую ONNX Runtime
|
||||||
|
session с общим пулом из восьми intra-op потоков; `inter_op_threads=1`.
|
||||||
|
|
||||||
|
Изолированный A/B механизма shared-session дал ускорение 24,05%. Первичный A/B
|
||||||
|
C2 показал 22,90%, поэтому кандидат прошел порог продолжения 10%, и для него
|
||||||
|
запустили пять чередующихся циклов. Они дали медиану 23,57% при MAD 0,62
|
||||||
|
процентного пункта, то есть ниже итогового порога 30%; по одному Ryzen-кандидату
|
||||||
|
C2 итог был бы `stop`.
|
||||||
|
|
||||||
|
Вся воронка дала `new map`. W1-W4 потеряли обязательное число кластеров. C1 с
|
||||||
|
CAMPPlus + NME ускорила Intel-прогон на 33,65%, но оставила два дополнительных
|
||||||
|
слова без говорящего; требуемую слуховую проверку не проводили и автоматической
|
||||||
|
оценкой не подменяли. Поэтому C1 не передавали на Ryzen и не считаем победителем.
|
||||||
|
Вернуть ее в матрицу можно только после приватной слуховой проверки.
|
||||||
|
|
||||||
|
Все принятые ячейки прошли обязательное качество: `3 / 2 / 2` кластера,
|
||||||
|
неизменный текст, `51 / 13 / 9` слов без говорящего, те же значения
|
||||||
|
`mapped speaker purity` `0,76655 / 0,78742`, `291 / 291 / 291` segmentation-окон
|
||||||
|
и `531 / 414 / 440` embedding-заданий. Потерь или дублей prepared-строк не было.
|
||||||
|
Пиковый RSS C2 оставался в диапазоне 410,3-418,1 МиБ; OOM, активности swap и
|
||||||
|
линейного роста памяти не наблюдалось.
|
||||||
|
|
||||||
|
Полные записи и слуховая приемка по пяти категориям на этапе 6 не запускались.
|
||||||
|
|
||||||
|
## Что именно проверяли
|
||||||
|
|
||||||
|
Отчет охватывает всю воронку: этапы 0-4 на Intel и этап 5 на Ryzen. На Intel
|
||||||
|
проверили шаг окна, модели эмбеддингов, счетчик говорящих, квантизацию, способы
|
||||||
|
параллельного исполнения и две комбинации. Таблицы ниже собирают этот путь в
|
||||||
|
одном месте. Подробные хеши и исходные идентификаторы ячеек остаются в
|
||||||
|
[Intel-отчете](2026-08-31-cpu-diarization-intel.md).
|
||||||
|
|
||||||
|
Intel-ячейки W/M/E/C были разведочными одиночными прогонами трех пятиминутных
|
||||||
|
фрагментов. Они показывают направление и причину отсева, но не дисперсию.
|
||||||
|
Исключение - соседняя пара W0/C2 под runtime guard. На Ryzen сначала выполнялась
|
||||||
|
одна диагностическая пара, затем первичная пара C2 и только после прохождения
|
||||||
|
порога - пять чередующихся циклов.
|
||||||
|
|
||||||
|
### Этап 0: пригодность механизмов
|
||||||
|
|
||||||
|
До дорогих CPU-ячеек проверили, что экспериментальный стенд вообще измеряет
|
||||||
|
задуманные механизмы, не теряя строки и не меняя пользовательский результат.
|
||||||
|
|
||||||
|
| Гипотеза | Что проверяли | Что получилось | Решение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Prepare/Finalize` | Подготовка один раз, кластеризация копии матрицы и финализация | Результат совпал со штатным `process()`; пропусков и дублей строк нет | Использовать в стенде |
|
||||||
|
| Счетчик eigengap | CAMPPlus и TitaNet на матрицах `531 / 414 / 440` строк | CAMPPlus дал `6 / 1 / 7`, TitaNet - `7 / 2 / 4` вместо `3 / 2 / 2` | Исключить |
|
||||||
|
| Счетчик NME | Те же шесть матриц без повторного embedding-инференса | Обе модели дали `3 / 2 / 2`; метки повторяемы, потерянных строк нет | Использовать для M2 и M3 |
|
||||||
|
| WeSpeaker S8S8 QDQ | Схема, opset, метаданные, динамические длины и загрузка через sherpa-onnx | Граф технически корректен; качество и скорость проверены отдельно в Q1 | Допустить к CPU-ячейке |
|
||||||
|
| Shared-session worker | Один и два worker на одном наборе prepared-входов | Строки, метки и интервалы совпали; гонок и нечисловых значений нет | Допустить E1/E2 |
|
||||||
|
|
||||||
|
Задача этапа 0 - подтвердить, что последующие CPU-ячейки сравнивают одинаковые
|
||||||
|
вычисления и используют исправный экспериментальный API. Скорость измеряли на
|
||||||
|
следующих этапах.
|
||||||
|
|
||||||
|
### Этап 1: можно ли считать меньше окон
|
||||||
|
|
||||||
|
Гипотеза: увеличить `window_shift_ratio`, сократить число segmentation-окон и
|
||||||
|
вместе с ними число embedding-заданий. Все варианты использовали Pyannote
|
||||||
|
Segmentation 3.0 FP32, WeSpeaker FP32 и текущий `FastClustering(0.89)`.
|
||||||
|
|
||||||
|
| Ячейка | Шаг | Окна по трем фрагментам | Embedding-задания | Время, с | Кластеры | Решение |
|
||||||
|
|---|---:|---|---|---:|---|---|
|
||||||
|
| W0 | 0,10 | `291 / 291 / 291` | `531 / 414 / 440` | 148,220 | `3 / 2 / 2` | baseline |
|
||||||
|
| W1 | 0,15 | `195 / 195 / 195` | `357 / 274 / 302` | 100,799 | `2 / 2 / 2` | исключена: потерян кластер |
|
||||||
|
| W2 | 0,20 | `146 / 146 / 146` | `264 / 206 / 222` | 76,127 | `2 / 2 / 2` | исключена: потерян кластер |
|
||||||
|
| W3 | 0,25 | `117 / 117 / 117` | `211 / 165 / 175` | 61,369 | `2 / 2 / 2` | исключена: потерян кластер |
|
||||||
|
| W4 | 0,50 | `59 / 59 / 59` | `105 / 82 / 89` | 31,291 | `1 / 2 / 2` | исключена: потеряны два кластера |
|
||||||
|
|
||||||
|
Скорость росла именно вместе с сокращением работы, но даже осторожный W1
|
||||||
|
нарушил обязательное качество на первой записи. Рабочим остался шаг `0.1`.
|
||||||
|
Комбинация C0 совпала бы с baseline, поэтому ее не запускали.
|
||||||
|
|
||||||
|
### Этап 2: можно ли удешевить модель эмбеддингов
|
||||||
|
|
||||||
|
Гипотеза: сохранить полный набор окон, но заменить WeSpeaker и при необходимости
|
||||||
|
счетчик говорящих. В M2 и M3 сохраняли `window_shift_ratio=0.1`, Pyannote
|
||||||
|
Segmentation 3.0 FP32 и последовательный инференс. Менялись модель эмбеддингов и
|
||||||
|
счетчик говорящих. Проценты ниже посчитаны относительно W0 148,220 с.
|
||||||
|
|
||||||
|
| Ячейка | Рецепт | Время, с | Изменение | RSS, МиБ | Кластеры | Решение |
|
||||||
|
|---|---|---:|---:|---:|---|---|
|
||||||
|
| W0 (рецепт M0) | WeSpeaker FP32 + `FastClustering(0.89)` | 148,220 | baseline | 362,6 | `3 / 2 / 2` | точка сравнения |
|
||||||
|
| M2 | CAMPPlus + NME + `FastClustering(N)` | 94,550 | -36,21% | 358,6 | `3 / 2 / 2` | самый быстрый, собрать C1 |
|
||||||
|
| M3 | TitaNet small + NME + `FastClustering(N)` | 107,205 | -27,67% | 511,9 | `3 / 2 / 2` | прошла, но медленнее и тяжелее M2 |
|
||||||
|
|
||||||
|
M2 и M3 прошли обязательное число кластеров. Окончательная диагностика M2
|
||||||
|
появилась в C1: purity выросла, но число слов без говорящего на первой записи
|
||||||
|
изменилось с 51 до 53. По спецификации это требует прослушивания отмеченных мест.
|
||||||
|
|
||||||
|
### Этап 3: квантизация и исполнение эмбеддингов
|
||||||
|
|
||||||
|
Для QDQ проверяли, даст ли INT8 выигрыш старому Intel без VNNI. Q1 и E1-E3
|
||||||
|
сохраняли `window_shift_ratio=0.1`, Pyannote Segmentation 3.0 FP32 и текущий
|
||||||
|
`FastClustering(0.89)`. Q1 меняла модель WeSpeaker, E1-E3 - только топологию
|
||||||
|
исполнения FP32-модели. Во всех вариантах `inter_op_threads=1`; E1-E3 использовали
|
||||||
|
общий бюджет четырех физических ядер.
|
||||||
|
|
||||||
|
| Ячейка | Рецепт | Время, с | Изменение к W0 | RSS, МиБ | Кластеры | Решение |
|
||||||
|
|---|---|---:|---:|---:|---|---|
|
||||||
|
| Q1 | WeSpeaker S8S8 QDQ, sequential | 177,537 | +19,78% | 367,7 | `2 / 2 / 2` | исключена: медленнее и потерян кластер |
|
||||||
|
| E1 | 2 outer worker, одна session, общий pool 4 | 139,219 | -6,07% | 398,6 | `3 / 2 / 2` | передать как аппаратный кандидат |
|
||||||
|
| E2 | 4 outer worker, одна session, `intra_op=1` | 152,167 | +2,66% | 551,2 | `3 / 2 / 2` | исключена по времени и памяти |
|
||||||
|
| E3 | 4 отдельные session, `intra_op=1` | 159,038 | +7,30% | 732,3 | `3 / 2 / 2` | отрицательный контроль подтвердился |
|
||||||
|
|
||||||
|
E3 измерялась составным интервалом на заранее подготовленных заданиях, поэтому
|
||||||
|
ее wall time нельзя сравнивать с `process()` так же строго, как E1/E2. Для
|
||||||
|
решения хватило отрицательного результата одновременно по времени и памяти.
|
||||||
|
|
||||||
|
TitaNet batch проверили отдельно на тех же трех матрицах:
|
||||||
|
|
||||||
|
| Контроль | Строк | Batch 1, с | Batch 4, с | Число кластеров | Метки строк совпали |
|
||||||
|
|---:|---:|---:|---:|---:|:---:|
|
||||||
|
| 1 | 531 | 27,175 | 37,588 | 3 | нет |
|
||||||
|
| 2 | 414 | 25,871 | 32,443 | 2 | нет |
|
||||||
|
| 3 | 440 | 22,877 | 28,565 | 2 | нет |
|
||||||
|
|
||||||
|
Batch 4 сохранил число кластеров, но оказался медленнее batch 1, поменял метки
|
||||||
|
строк на всех трех матрицах и дал максимальный дрейф эмбеддингов `0,277-0,317`.
|
||||||
|
Ветвь остановили до отдельной CPU-ячейки. SHA-256 закрытого batch-result:
|
||||||
|
`f12bfdb5cadf80c62871c0725eac744a99825cb5d06b70fcf11e4e538ff6df72`.
|
||||||
|
|
||||||
|
### Этап 4: комбинации перед Ryzen
|
||||||
|
|
||||||
|
Полного декартова перебора не было. C0 совпала с baseline; из оставшихся
|
||||||
|
собрали самый быстрый модельный вариант C1 и переносимый аппаратный вариант C2.
|
||||||
|
|
||||||
|
| Комбинация | Точный рецепт | Время, с | Ускорение | RSS, МиБ | Диагностика | Решение |
|
||||||
|
|---|---|---:|---:|---:|---|---|
|
||||||
|
| C1 | `0.1` + Pyannote Segmentation 3.0 FP32 + CAMPPlus + NME + `FastClustering(N)` + sequential | 98,347 | 33,65% | 399,5 | `3 / 2 / 2`; purity выше; `+2` слова без говорящего | не передавать без слуховой проверки |
|
||||||
|
| C2 | `0.1` + Pyannote Segmentation 3.0 FP32 + WeSpeaker ResNet34 LM FP32 + `FastClustering(0.89)` + 2 outer worker + 1 shared session + pool 4 | 140,632 | 4,32% | 406,0 | точное совпадение с соседним W0 | передать на Ryzen |
|
||||||
|
|
||||||
|
У C1 процент считается от разведочного W0 148,220 с. У C2 был соседний W0
|
||||||
|
146,978 с под тем же runtime guard. C2 не достигла Intel-порога продолжения,
|
||||||
|
но shared-session заранее считалась аппаратной гипотезой: Ryzen имеет восемь
|
||||||
|
физических ядер и другой ISA, поэтому спецификация требовала отдельный A/B.
|
||||||
|
|
||||||
|
## Handoff и воспроизводимость
|
||||||
|
|
||||||
|
| Объект | Значение |
|
||||||
|
|---|---|
|
||||||
|
| Merge commit в начале Ryzen-этапа | `68fda7ee893265acfa00ac78b8d4c9697f9916d9` |
|
||||||
|
| Handoff commit | `42f094ccb9464742683eec9d695e5aa9a69dd4ec` |
|
||||||
|
| Source commit эксперимента | `6345cb6449d906767b6ed33e695f2ca4eeaf3008` |
|
||||||
|
| SHA-256 Intel-отчета | `8d4183be4c3ab35f2ea043f8d87ca20e16f523cb60f9c5414d974c1bc4d6b7de` |
|
||||||
|
| SHA-256 входной Intel-капсулы | `a3a50d34783d24fe6cd13b154ca9813f9842fdcb08d99eab33a6a54efea1e3ae` |
|
||||||
|
| ID закрытого Ryzen-хранилища | `ryzen-42-20260831` |
|
||||||
|
| Upstream commit sherpa-onnx 1.13.6 | `1cb484af9b1ab05cc36f89e03dd7d76322f2e911` |
|
||||||
|
| SHA-256 патча sherpa-onnx 1.13.6 | `5f12df858cd525c8a0cfe8ff95b647c3b0e915a13d45621acc4ac42660014c49` |
|
||||||
|
| SHA-256 Intel-wheel | `306499d8668ee138357f1de43a5c1c88f4dc46c845e8a92e6ad945327bcaa050` |
|
||||||
|
| SHA-256 Ryzen-wheel | `baaaa2532606f1e1411e5c84655afa70107ad4b1676420953f5e67233c46a484` |
|
||||||
|
|
||||||
|
Входная Intel-капсула прошла полную проверку: schema v3, 199 файлов,
|
||||||
|
QDQ-артефакт доступен, предупреждений нет. Ryzen-manifest без изменений
|
||||||
|
зафиксировал данные Intel-капсулы: handoff, артефакты, контрольные записи,
|
||||||
|
calibration, QDQ, патч, версии runtime и интервал RSS. Рецепты baseline и
|
||||||
|
кандидата во всех запущенных парах совпали с переносимыми Intel-рецептами.
|
||||||
|
|
||||||
|
Ryzen-manifest, сырые результаты, рабочие артефакты стенда, guard-свидетельства
|
||||||
|
и журналы сохранены в закрытой папке Яндекс Диска. Чтобы восстановить
|
||||||
|
Ryzen-этап, нужны это хранилище и входная Intel-капсула; публичный отчет связывает
|
||||||
|
приведенные ниже восемь результатов с закрытым хранилищем только по SHA-256.
|
||||||
|
|
||||||
|
Переданный wheel не загрузился в Ryzen WSL: он требовал GLIBC 2.43, а стенд
|
||||||
|
работает с GLIBC 2.39. Для измерений wheel пересобран из того же upstream commit
|
||||||
|
и патча под текущую систему; его отдельный SHA-256 приведен в
|
||||||
|
таблице. Production-зависимости и lock-файл не менялись.
|
||||||
|
|
||||||
|
Стенд потребовал два исправления, не меняющих пользовательский контракт. Ранее
|
||||||
|
Ryzen-запуск сравнивал свой manifest побайтово с Intel `manifest.json` внутри
|
||||||
|
капсулы, хотя обязательные `stage=ryzen`, Ryzen-машина и расписание такое
|
||||||
|
совпадение исключают. Теперь Intel-manifest проверяется по индексу капсулы, а
|
||||||
|
Ryzen-manifest отдельно связывается с handoff и входами капсулы. Второе:
|
||||||
|
`temperature_celsius` допускает `null`, когда WSL и Windows не показывают
|
||||||
|
датчик температуры; отсутствие измерения больше не подменяется числом.
|
||||||
|
|
||||||
|
## Стенд Ryzen
|
||||||
|
|
||||||
|
- AMD Ryzen 7 8845H with Radeon 780M, 8 физических ядер / 16 логических
|
||||||
|
процессоров;
|
||||||
|
- 15 584 276 480 байт RAM доступны WSL, 31 944 794 112 байт установлены в
|
||||||
|
Windows; swap WSL - 4 294 967 296 байт, активность во время принятых ячеек -
|
||||||
|
нулевая;
|
||||||
|
- x86_64; AVX, AVX2, AVX-512, AVX512 VNNI, FMA, SSE4.1, SSE4.2 и AES;
|
||||||
|
- WSL2 Linux 5.15.167.4 с GLIBC 2.39, Windows 11 Enterprise 10.0.26200,
|
||||||
|
Python 3.13.13;
|
||||||
|
- `sherpa-onnx` / `sherpa-onnx-core` 1.13.6, ONNX Runtime 1.28.0,
|
||||||
|
NumPy 2.4.3, ONNX 1.22.0, kaldi-native-fbank 1.22.3, psutil 7.2.2;
|
||||||
|
- питание от сети, стабильный Windows-профиль `balanced`, EPP AC 25%,
|
||||||
|
максимальное состояние процессора 100%, boost mode `aggressive`;
|
||||||
|
- WSL не предоставляет governor, а доступные интерфейсы не показывают
|
||||||
|
температурный датчик или аппаратные лимиты мощности. Температура
|
||||||
|
записана как недоступная; это ограничивает тепловой guard;
|
||||||
|
- нативные WSL-интерфейсы не отдали supply, platform profile и throttle count,
|
||||||
|
поэтому для принятых ячеек использовали override
|
||||||
|
`LOCAL_TRANSCRIBER_RUNTIME_GUARD_JSON`, заполненный проверенными Windows-фактами:
|
||||||
|
AC, `balanced`, turbo, power policy, нулевая подкачка, отсутствие throttling.
|
||||||
|
До и после принятых пар Windows-проверка подтверждала AC, неизменный `balanced`
|
||||||
|
и отсутствие событий throttling от `Microsoft-Windows-Kernel-Processor-Power`.
|
||||||
|
Статический override сам по себе не обнаруживает изменение внутри ячейки,
|
||||||
|
поэтому внешняя проверка остается ограничением воспроизводимости;
|
||||||
|
- baseline: одна session, один outer worker, `intra_op_threads=8`,
|
||||||
|
`inter_op_threads=1`, batch 1; C2: одна shared session, два outer worker,
|
||||||
|
общий `intra_op_threads=8`, `inter_op_threads=1`, batch 1.
|
||||||
|
|
||||||
|
## Ячейки этапа 5
|
||||||
|
|
||||||
|
Время ячейки - сумма трех вызовов `diarizer.process()`, по одному на каждый
|
||||||
|
пятиминутный фрагмент. ASR и загрузка моделей в измерение не входят.
|
||||||
|
|
||||||
|
| Проверка | Порядок | Baseline, с | Кандидат, с | Ускорение | RSS baseline / кандидат, МиБ | Качество |
|
||||||
|
|---|---|---:|---:|---:|---:|:---:|
|
||||||
|
| Совпадающий baseline | W0 | 110,998 | - | - | 370,4 / - | да |
|
||||||
|
| Изолированный E1 | W0 -> E1 | 111,557 | 84,723 | 24,05% | 367,3 / 412,2 | да |
|
||||||
|
| Первичный C2 | C2 -> W0 | 111,423 | 85,909 | 22,90% | 369,6 / 410,3 | да |
|
||||||
|
|
||||||
|
E1 и C2 сохранили все обязательные и диагностические показатели baseline.
|
||||||
|
Первичный C2 прошел порог продолжения и уложился в правило памяти.
|
||||||
|
|
||||||
|
## Пять финальных A/B-циклов
|
||||||
|
|
||||||
|
| Цикл | Порядок | Baseline, с | C2, с | Парное ускорение | RSS baseline / C2, МиБ | Качество |
|
||||||
|
|---:|---|---:|---:|---:|---:|:---:|
|
||||||
|
| 1 | W0 -> C2 | 111,354 | 83,529 | 24,99% | 369,2 / 413,4 | да |
|
||||||
|
| 2 | C2 -> W0 | 108,899 | 83,907 | 22,95% | 373,0 / 418,1 | да |
|
||||||
|
| 3 | W0 -> C2 | 110,928 | 84,968 | 23,40% | 373,8 / 410,9 | да |
|
||||||
|
| 4 | C2 -> W0 | 114,985 | 83,342 | 27,52% | 376,6 / 411,1 | да |
|
||||||
|
| 5 | W0 -> C2 | 113,233 | 86,548 | 23,57% | 358,6 / 413,4 | да |
|
||||||
|
| **Итог** | - | - | - | **медиана 23,57%; MAD 0,62 п. п.** | - | **да** |
|
||||||
|
|
||||||
|
Чередование не изменило вывод: даже лучший отдельный цикл остался ниже 30%, а
|
||||||
|
медиана недобрала 6,43 процентного пункта. На Intel тот же механизм давал только
|
||||||
|
4,32%; Ryzen ускоряет shared-session заметнее, но недостаточно для `go`.
|
||||||
|
|
||||||
|
## Что уже не нужно прогонять заново
|
||||||
|
|
||||||
|
Полный replay этапов 0-5 для следующей гипотезы не нужен. Результаты закреплены
|
||||||
|
публичными агрегатами, хешами и закрытой капсулой.
|
||||||
|
|
||||||
|
| Результат | Что можно переиспользовать | Когда повтор действительно нужен |
|
||||||
|
|---|---|---|
|
||||||
|
| W1-W4 | На текущей Pyannote-модели увеличение шага теряет кластер раньше порога скорости | Изменилась segmentation-модель, логика сборки окон или обязательный quality gate |
|
||||||
|
| M2/M3 | CAMPPlus и TitaNet с NME сохраняют `3 / 2 / 2`; M2 быстрее и экономнее | Изменилась модель, счетчик, clustering или контрольные входы |
|
||||||
|
| Q1 | Этот S8S8 QDQ-граф на Intel медленнее FP32 и дает `2 / 2 / 2` | Новая калибровка, формат квантизации, модель или runtime |
|
||||||
|
| E2/E3 | Больше worker и отдельные session не окупились при прежнем бюджете потоков | Изменилась топология session, runtime или бюджет ядер |
|
||||||
|
| E1/C2 на Ryzen | Для точного рецепта shared-session медиана равна 23,57%, качество и память проходят | Изменилась реализация механизма, runtime, машина или режим питания |
|
||||||
|
| C1 | Замер скорости уже есть; открыт только диагностический слуховой gate | Сначала прослушать подготовленные места; новый CPU-замер нужен лишь после положительного решения |
|
||||||
|
|
||||||
|
Есть три разных вида проверки, и они не заменяют друг друга:
|
||||||
|
|
||||||
|
1. `--validate-only` сверяет commit, схему, хеши, handoff и план. Модели он не
|
||||||
|
запускает.
|
||||||
|
2. `uv run pytest` проверяет код стенда на mock-данных. Полный Python-suite перед
|
||||||
|
PR нужен, но он не повторяет аудиозамеры и не скачивает модели.
|
||||||
|
3. Измерительная ячейка запускает реальные модели. Для новой гипотезы нужен
|
||||||
|
только затронутый минимальный срез, один совпадающий baseline и первичный A/B.
|
||||||
|
Пять циклов и полные записи нужны лишь кандидату, прошедшему записанные пороги.
|
||||||
|
|
||||||
|
## Что осталось неизвестным
|
||||||
|
|
||||||
|
- C1 не получила слухового решения. Из автоматических метрик нельзя вывести,
|
||||||
|
допустимы ли два дополнительных слова без говорящего.
|
||||||
|
- На Ryzen не опубликовано разложение wall time на segmentation, embeddings,
|
||||||
|
clustering и orchestration. По общему времени нельзя надежно назвать следующий
|
||||||
|
доминирующий компонент.
|
||||||
|
- Полные записи не запускались: C2 не достигла порога, а C1 не дошла до Ryzen.
|
||||||
|
- Температурный датчик в WSL и Windows недоступен. Внешняя проверка питания и
|
||||||
|
throttling снижает риск дрейфа, но не заменяет измерение температуры во время
|
||||||
|
каждой ячейки.
|
||||||
|
- Эксперимент проверял локальные замены внутри прежнего каскада. End-to-end
|
||||||
|
архитектуры в эту матрицу не входили.
|
||||||
|
|
||||||
|
## Индекс публичных доказательств
|
||||||
|
|
||||||
|
Хеши связывают обезличенные агрегаты с приватными JSON-результатами и не
|
||||||
|
раскрывают пути, текст, интервалы и идентификаторы записей.
|
||||||
|
|
||||||
|
| Результат | SHA-256 |
|
||||||
|
|---|---|
|
||||||
|
| Совпадающий baseline | `f50684784392cacc2d99c8aee51d01f0a67dfee3da6ea3ba5f3db749a3d5287a` |
|
||||||
|
| Изолированный E1 A/B | `24d5edae117e0619e432ef3f3019e8c66cf3516b87b80b64b8591390db25bf48` |
|
||||||
|
| Первичный C2 A/B | `142db6d06efc8d558b38e18f47f47e3e35e7df00751da2583339f99add8d75c4` |
|
||||||
|
| Финальный цикл 1 | `4b5095c9e64ff0ef75bbc58516c662bedbbdb0bedf131f66cc1031c9a0ad8c7f` |
|
||||||
|
| Финальный цикл 2 | `256a17b2d08510aad03d43f5c626c1a4269c9a25271ce492dc3305c4afca1888` |
|
||||||
|
| Финальный цикл 3 | `b5d37c7a8b05b065c61bc9d5f1c0ab639a80a821730e505179388d90c4239caa` |
|
||||||
|
| Финальный цикл 4 | `639cc9f13511a59c7240c6fb6ec6bf1c31ec26350330e3ca0c64fb721b768736` |
|
||||||
|
| Финальный цикл 5 | `4ff2863a93fc22a037de5c292391b6b46de1b539cf397026b349ea5134cf1051` |
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Итог - `new map`:
|
||||||
|
|
||||||
|
1. C2 прошла обязательное качество и память.
|
||||||
|
2. Медианное ускорение 23,57% не достигло порога 30%.
|
||||||
|
3. W1-W4 потеряли кластер, а C1 преодолела 30% на Intel только с незакрытым
|
||||||
|
диагностическим слуховым gate: сокращение окон и цены эмбеддинга уперлось в
|
||||||
|
качество раньше итогового порога.
|
||||||
|
4. По правилу спецификации следующий шаг - отдельная карта end-to-end
|
||||||
|
диаризации, заведенная как
|
||||||
|
[задача #45](https://git.dementev.space/ddmitry/local-transcriber/issues/45).
|
||||||
|
Ее гипотезы и замеры относятся к отдельному эксперименту.
|
||||||
|
5. Полные записи и Ryzen-слуховая приемка не требуются без победителя.
|
||||||
|
6. Production-профиль, CLI, конфигурационный контракт и модель по умолчанию
|
||||||
|
остаются без изменений.
|
||||||
@@ -370,11 +370,13 @@ def validate_manifest(
|
|||||||
if machine["cpu_flags"] != sorted(set(machine["cpu_flags"])):
|
if machine["cpu_flags"] != sorted(set(machine["cpu_flags"])):
|
||||||
raise ValueError("machine.cpu_flags: список должен быть сортирован без дублей")
|
raise ValueError("machine.cpu_flags: список должен быть сортирован без дублей")
|
||||||
_exact_keys(machine["power"], POWER_KEYS, "machine.power")
|
_exact_keys(machine["power"], POWER_KEYS, "machine.power")
|
||||||
_require_positive(
|
temperature = machine["power"]["temperature_celsius"]
|
||||||
machine["power"]["temperature_celsius"],
|
if temperature is not None:
|
||||||
"machine.power.temperature_celsius",
|
_require_positive(
|
||||||
allow_zero=True,
|
temperature,
|
||||||
)
|
"machine.power.temperature_celsius",
|
||||||
|
allow_zero=True,
|
||||||
|
)
|
||||||
_require_positive(
|
_require_positive(
|
||||||
machine["power"]["swap_total_bytes"],
|
machine["power"]["swap_total_bytes"],
|
||||||
"machine.power.swap_total_bytes",
|
"machine.power.swap_total_bytes",
|
||||||
@@ -714,14 +716,16 @@ def _validate_handoff(
|
|||||||
)
|
)
|
||||||
name = _require_id(candidate["name"], f"handoff.candidate_recipes:{index}.name")
|
name = _require_id(candidate["name"], f"handoff.candidate_recipes:{index}.name")
|
||||||
candidate_names.append(name)
|
candidate_names.append(name)
|
||||||
cell = combination_cells.get(name)
|
_require_sha256(candidate["cell_id"], f"handoff:{name}.cell_id")
|
||||||
if cell is None:
|
if experiment["stage"] == "intel":
|
||||||
raise ValueError(
|
cell = combination_cells.get(name)
|
||||||
"handoff: кандидат не описан включенной combination-ячейкой"
|
if cell is None:
|
||||||
)
|
raise ValueError(
|
||||||
expected_cell_id = make_cell_id(manifest, cell)
|
"handoff: кандидат не описан включенной combination-ячейкой"
|
||||||
if candidate["cell_id"] != expected_cell_id:
|
)
|
||||||
raise ValueError(f"handoff:{name}: cell_id не совпадает с manifest")
|
expected_cell_id = make_cell_id(manifest, cell)
|
||||||
|
if candidate["cell_id"] != expected_cell_id:
|
||||||
|
raise ValueError(f"handoff:{name}: cell_id не совпадает с manifest")
|
||||||
_require_sha256(candidate["result_sha256"], f"handoff:{name}.result_sha256")
|
_require_sha256(candidate["result_sha256"], f"handoff:{name}.result_sha256")
|
||||||
_require_relative_path(candidate["result_file"], f"handoff:{name}.result_file")
|
_require_relative_path(candidate["result_file"], f"handoff:{name}.result_file")
|
||||||
for gate in ("mandatory_passed", "memory_passed", "diagnostic_approved"):
|
for gate in ("mandatory_passed", "memory_passed", "diagnostic_approved"):
|
||||||
@@ -1673,16 +1677,156 @@ def _manifest_file_index(manifest: Mapping[str, Any]) -> dict[str, str]:
|
|||||||
return required
|
return required
|
||||||
|
|
||||||
|
|
||||||
|
def _portable_inference_recipe(
|
||||||
|
manifest: Mapping[str, Any], inference: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Переносит только заданные спецификацией конфигурации с бюджетом P."""
|
||||||
|
result = dict(inference)
|
||||||
|
physical_cores = manifest["machine"]["physical_cores"]
|
||||||
|
mode = result["mode"]
|
||||||
|
uses_physical_intra = result["intra_op_threads"] == physical_cores and (
|
||||||
|
(
|
||||||
|
mode in {"sequential", "titanet-batch"}
|
||||||
|
and result["outer_workers"] == 1
|
||||||
|
and result["session_count"] == 1
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
mode == "shared-session"
|
||||||
|
and result["outer_workers"] == 2
|
||||||
|
and result["session_count"] == 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if uses_physical_intra:
|
||||||
|
result["intra_op_threads"] = "physical-cores"
|
||||||
|
elif (
|
||||||
|
mode == "shared-session"
|
||||||
|
and result["outer_workers"] == physical_cores
|
||||||
|
and result["session_count"] == 1
|
||||||
|
and result["intra_op_threads"] == 1
|
||||||
|
):
|
||||||
|
result["outer_workers"] = "physical-cores"
|
||||||
|
elif (
|
||||||
|
mode == "separate-session"
|
||||||
|
and result["outer_workers"] == physical_cores
|
||||||
|
and result["session_count"] == physical_cores
|
||||||
|
and result["intra_op_threads"] == 1
|
||||||
|
):
|
||||||
|
result["outer_workers"] = "physical-cores"
|
||||||
|
result["session_count"] = "physical-cores"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _portable_cell_recipe(
|
||||||
|
manifest: Mapping[str, Any], cell: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Нормализует переносимый между машинами рецепт ячейки."""
|
||||||
|
return {
|
||||||
|
"window_shift_ratio": cell["window_shift_ratio"],
|
||||||
|
"segmentation_artifact_id": cell["segmentation_artifact_id"],
|
||||||
|
"embedding_artifact_id": cell["embedding_artifact_id"],
|
||||||
|
"counter": cell["counter"],
|
||||||
|
"clustering": cell["clustering"],
|
||||||
|
"inference": _portable_inference_recipe(manifest, cell["inference"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ryzen_capsule_manifest(
|
||||||
|
ryzen_manifest: Mapping[str, Any], intel_manifest: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Связывает Ryzen-план с проверенным Intel manifest из капсулы."""
|
||||||
|
if intel_manifest.get("experiment", {}).get("stage") != "intel":
|
||||||
|
raise ValueError("Капсула Ryzen должна содержать Intel manifest")
|
||||||
|
if ryzen_manifest["experiment"]["stage"] != "ryzen":
|
||||||
|
raise ValueError("Переносимый manifest должен относиться к Ryzen")
|
||||||
|
if ryzen_manifest["handoff"] != intel_manifest.get("handoff"):
|
||||||
|
raise ValueError("Ryzen manifest не совпал с handoff из капсулы")
|
||||||
|
ryzen_experiment = ryzen_manifest["experiment"]
|
||||||
|
intel_experiment = intel_manifest["experiment"]
|
||||||
|
for key in ("sherpa_patch_sha256", "dependencies"):
|
||||||
|
if ryzen_experiment[key] != intel_experiment.get(key):
|
||||||
|
raise ValueError(
|
||||||
|
"Ryzen manifest изменил закрепленный runtime Intel-капсулы"
|
||||||
|
)
|
||||||
|
if ryzen_manifest["rss_sample_interval_ms"] != intel_manifest.get(
|
||||||
|
"rss_sample_interval_ms"
|
||||||
|
):
|
||||||
|
raise ValueError("Ryzen manifest изменил интервал измерения RSS")
|
||||||
|
for key in ("artifacts", "recordings", "calibration", "qdq"):
|
||||||
|
if ryzen_manifest[key] != intel_manifest.get(key):
|
||||||
|
raise ValueError(
|
||||||
|
"Ryzen manifest изменил закрепленные входы Intel-капсулы"
|
||||||
|
)
|
||||||
|
intel_cells_by_id = {
|
||||||
|
make_cell_id(intel_manifest, cell): cell
|
||||||
|
for cell in intel_manifest["cells"]
|
||||||
|
if cell["phase"] == "combination" and cell["enabled"]
|
||||||
|
}
|
||||||
|
handed_candidate_recipes = []
|
||||||
|
handed_baseline_recipes = []
|
||||||
|
handed_mechanism_recipes = []
|
||||||
|
for candidate in intel_manifest["handoff"]["candidate_recipes"]:
|
||||||
|
cell = intel_cells_by_id.get(candidate["cell_id"])
|
||||||
|
if cell is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Handoff-кандидат не связан с Intel combination-ячейкой"
|
||||||
|
)
|
||||||
|
baselines = [
|
||||||
|
baseline
|
||||||
|
for baseline in intel_manifest["cells"]
|
||||||
|
if baseline["enabled"]
|
||||||
|
and baseline["pair_id"] == cell["pair_id"]
|
||||||
|
and baseline["pair_role"] == "baseline"
|
||||||
|
]
|
||||||
|
if len(baselines) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Handoff-кандидат не связан с Intel baseline-ячейкой"
|
||||||
|
)
|
||||||
|
candidate_recipe = _portable_cell_recipe(intel_manifest, cell)
|
||||||
|
baseline_recipe = _portable_cell_recipe(intel_manifest, baselines[0])
|
||||||
|
handed_candidate_recipes.append(candidate_recipe)
|
||||||
|
handed_baseline_recipes.append(baseline_recipe)
|
||||||
|
for key in ("embedding_artifact_id", "inference"):
|
||||||
|
if candidate_recipe[key] == baseline_recipe[key]:
|
||||||
|
continue
|
||||||
|
mechanism_recipe = copy.deepcopy(baseline_recipe)
|
||||||
|
mechanism_recipe[key] = candidate_recipe[key]
|
||||||
|
handed_mechanism_recipes.append(mechanism_recipe)
|
||||||
|
for cell in ryzen_manifest["cells"]:
|
||||||
|
if not cell["enabled"]:
|
||||||
|
continue
|
||||||
|
if cell["phase"] not in {"baseline", "mechanism", "combination"}:
|
||||||
|
raise ValueError("Ryzen manifest содержит ячейку вне этапов 5-6")
|
||||||
|
recipe = _portable_cell_recipe(ryzen_manifest, cell)
|
||||||
|
if cell["phase"] == "baseline" and cell["pair_role"] == "candidate":
|
||||||
|
raise ValueError("Ryzen baseline-ячейка не может быть кандидатом")
|
||||||
|
if cell["pair_role"] == "baseline" or cell["phase"] == "baseline":
|
||||||
|
if recipe not in handed_baseline_recipes:
|
||||||
|
raise ValueError(
|
||||||
|
"Ryzen baseline-ячейка не совпала с Intel baseline"
|
||||||
|
)
|
||||||
|
elif cell["phase"] == "mechanism" and recipe not in handed_mechanism_recipes:
|
||||||
|
raise ValueError(
|
||||||
|
"Ryzen mechanism-ячейка не совпала с механизмом handoff"
|
||||||
|
)
|
||||||
|
elif cell["phase"] == "combination" and recipe not in handed_candidate_recipes:
|
||||||
|
raise ValueError(
|
||||||
|
"Ryzen combination-ячейка не совпала с рецептом handoff"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_capsule(
|
def verify_capsule(
|
||||||
archive_path: Path,
|
archive_path: Path,
|
||||||
expected_sha256: str,
|
expected_sha256: str,
|
||||||
*,
|
*,
|
||||||
expected_manifest_path: Path,
|
expected_manifest_path: Path | None,
|
||||||
|
expected_ryzen_manifest: Mapping[str, Any] | None = None,
|
||||||
expected_source_commit: str | None = None,
|
expected_source_commit: str | None = None,
|
||||||
expected_report_sha256: str | None = None,
|
expected_report_sha256: str | None = None,
|
||||||
expected_capsule_id: str | None = None,
|
expected_capsule_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Сверяет внешний и внутренние хеши приватной ZIP-капсулы."""
|
"""Сверяет внешний и внутренние хеши приватной ZIP-капсулы."""
|
||||||
|
if (expected_manifest_path is None) == (expected_ryzen_manifest is None):
|
||||||
|
raise ValueError("Нужно выбрать ровно один Intel или Ryzen manifest")
|
||||||
if file_sha256(archive_path) != expected_sha256:
|
if file_sha256(archive_path) != expected_sha256:
|
||||||
raise ValueError("SHA-256 ZIP-капсулы не совпал")
|
raise ValueError("SHA-256 ZIP-капсулы не совпал")
|
||||||
with zipfile.ZipFile(archive_path) as archive:
|
with zipfile.ZipFile(archive_path) as archive:
|
||||||
@@ -1733,14 +1877,19 @@ def verify_capsule(
|
|||||||
actual = hashlib.sha256(archive.read(name)).hexdigest()
|
actual = hashlib.sha256(archive.read(name)).hexdigest()
|
||||||
if actual != expected:
|
if actual != expected:
|
||||||
raise ValueError("Внутренний SHA-256 капсулы не совпал")
|
raise ValueError("Внутренний SHA-256 капсулы не совпал")
|
||||||
manifest_bytes = expected_manifest_path.read_bytes()
|
|
||||||
archived_manifest = archive.read("manifest.json")
|
archived_manifest = archive.read("manifest.json")
|
||||||
if archived_manifest != manifest_bytes:
|
if expected_manifest_path is not None:
|
||||||
raise ValueError("manifest аргумента не совпал с manifest.json капсулы")
|
manifest_bytes = expected_manifest_path.read_bytes()
|
||||||
manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest()
|
if archived_manifest != manifest_bytes:
|
||||||
|
raise ValueError("manifest аргумента не совпал с manifest.json капсулы")
|
||||||
|
manifest_sha256 = hashlib.sha256(archived_manifest).hexdigest()
|
||||||
if declared["manifest.json"] != manifest_sha256:
|
if declared["manifest.json"] != manifest_sha256:
|
||||||
raise ValueError("Хеш manifest.json не совпал с индексом капсулы")
|
raise ValueError("Хеш manifest.json не совпал с индексом капсулы")
|
||||||
archived_manifest_value = json.loads(archived_manifest)
|
archived_manifest_value = json.loads(archived_manifest)
|
||||||
|
if expected_ryzen_manifest is not None:
|
||||||
|
_validate_ryzen_capsule_manifest(
|
||||||
|
expected_ryzen_manifest, archived_manifest_value
|
||||||
|
)
|
||||||
for name, expected in _manifest_file_index(archived_manifest_value).items():
|
for name, expected in _manifest_file_index(archived_manifest_value).items():
|
||||||
if declared.get(name) != expected:
|
if declared.get(name) != expected:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -1783,10 +1932,12 @@ def run_cli(args: Any, raw_manifest: Mapping[str, Any]) -> None:
|
|||||||
raise ValueError("Итоговый handoff требует --capsule и --capsule-sha256")
|
raise ValueError("Итоговый handoff требует --capsule и --capsule-sha256")
|
||||||
capsule_result = None
|
capsule_result = None
|
||||||
if capsule_path is not None and capsule_sha256 is not None:
|
if capsule_path is not None and capsule_sha256 is not None:
|
||||||
|
ryzen_stage = manifest["experiment"]["stage"] == "ryzen"
|
||||||
capsule_result = verify_capsule(
|
capsule_result = verify_capsule(
|
||||||
capsule_path,
|
capsule_path,
|
||||||
capsule_sha256,
|
capsule_sha256,
|
||||||
expected_manifest_path=args.manifest,
|
expected_manifest_path=None if ryzen_stage else args.manifest,
|
||||||
|
expected_ryzen_manifest=raw_manifest if ryzen_stage else None,
|
||||||
expected_source_commit=manifest["handoff"]["source_commit"],
|
expected_source_commit=manifest["handoff"]["source_commit"],
|
||||||
expected_report_sha256=manifest["handoff"]["public_report_sha256"],
|
expected_report_sha256=manifest["handoff"]["public_report_sha256"],
|
||||||
expected_capsule_id=manifest["handoff"]["capsule_id"],
|
expected_capsule_id=manifest["handoff"]["capsule_id"],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -206,6 +207,119 @@ def _environment() -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalized_handoff_manifest(tmp_path: Path) -> dict[str, object]:
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
baseline = manifest["cells"][0]
|
||||||
|
baseline.update(
|
||||||
|
name="W0",
|
||||||
|
phase="combination",
|
||||||
|
pair_id="combination-c2",
|
||||||
|
pair_role="baseline",
|
||||||
|
schedule_position=10,
|
||||||
|
)
|
||||||
|
candidate = copy.deepcopy(baseline)
|
||||||
|
candidate.update(
|
||||||
|
name="C2",
|
||||||
|
pair_role="candidate",
|
||||||
|
schedule_position=11,
|
||||||
|
)
|
||||||
|
candidate["inference"].update(
|
||||||
|
mode="shared-session",
|
||||||
|
outer_workers=2,
|
||||||
|
session_count=1,
|
||||||
|
intra_op_threads=4,
|
||||||
|
)
|
||||||
|
manifest["cells"] = [baseline, candidate]
|
||||||
|
cell_id = experiment.make_cell_id(manifest, candidate)
|
||||||
|
manifest["handoff"] = {
|
||||||
|
"outcome": "handoff",
|
||||||
|
"source_commit": manifest["experiment"]["source_commit"],
|
||||||
|
"candidate_recipes": [
|
||||||
|
{
|
||||||
|
"name": "C2",
|
||||||
|
"cell_id": cell_id,
|
||||||
|
"result_file": "results/w0.json",
|
||||||
|
"result_sha256": "3" * 64,
|
||||||
|
"mandatory_passed": True,
|
||||||
|
"memory_passed": True,
|
||||||
|
"diagnostic_approved": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"public_report_sha256": "4" * 64,
|
||||||
|
"capsule_id": "opaque-41",
|
||||||
|
"capsule_sha256": "5" * 64,
|
||||||
|
}
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _ryzen_capsule_fixture(tmp_path: Path):
|
||||||
|
intel = _finalized_handoff_manifest(tmp_path)
|
||||||
|
payloads = {}
|
||||||
|
|
||||||
|
def make_relative(item, archive_path):
|
||||||
|
content = Path(item["path"]).read_bytes()
|
||||||
|
item["path"] = archive_path
|
||||||
|
payloads[archive_path] = content
|
||||||
|
local_path = tmp_path / archive_path
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path.write_bytes(content)
|
||||||
|
|
||||||
|
for artifact in intel["artifacts"]:
|
||||||
|
make_relative(artifact, f"artifacts/{artifact['id']}.bin")
|
||||||
|
for recording in intel["recordings"]:
|
||||||
|
make_relative(recording, f"recordings/{recording['id']}.wav")
|
||||||
|
make_relative(
|
||||||
|
recording["asr_words"], f"sidecars/{recording['id']}.words.json"
|
||||||
|
)
|
||||||
|
if recording["reference"] is not None:
|
||||||
|
make_relative(
|
||||||
|
recording["reference"], f"references/{recording['id']}.md"
|
||||||
|
)
|
||||||
|
for source in intel["calibration"]:
|
||||||
|
make_relative(source, f"calibration/{source['id']}.wav")
|
||||||
|
|
||||||
|
result_bytes = b'{"aggregate":{"quality_passed":true}}'
|
||||||
|
result_path = "results/c2.json"
|
||||||
|
payloads[result_path] = result_bytes
|
||||||
|
candidate = intel["handoff"]["candidate_recipes"][0]
|
||||||
|
candidate["result_file"] = result_path
|
||||||
|
candidate["result_sha256"] = hashlib.sha256(result_bytes).hexdigest()
|
||||||
|
intel_bytes = json.dumps(intel, separators=(",", ":")).encode()
|
||||||
|
files = {
|
||||||
|
"manifest.json": hashlib.sha256(intel_bytes).hexdigest(),
|
||||||
|
**{name: hashlib.sha256(value).hexdigest() for name, value in payloads.items()},
|
||||||
|
}
|
||||||
|
capsule = {
|
||||||
|
"capsule_id": intel["handoff"]["capsule_id"],
|
||||||
|
"source_commit": intel["handoff"]["source_commit"],
|
||||||
|
"public_report_sha256": intel["handoff"]["public_report_sha256"],
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
archive_path = tmp_path / "capsule.zip"
|
||||||
|
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||||
|
archive.writestr("capsule.json", json.dumps(capsule))
|
||||||
|
archive.writestr("manifest.json", intel_bytes)
|
||||||
|
for name, content in payloads.items():
|
||||||
|
archive.writestr(name, content)
|
||||||
|
|
||||||
|
ryzen = copy.deepcopy(intel)
|
||||||
|
ryzen["experiment"].update(
|
||||||
|
id="cpu-diarization-ryzen",
|
||||||
|
stage="ryzen",
|
||||||
|
handoff_commit="6" * 40,
|
||||||
|
)
|
||||||
|
ryzen["machine"].update(
|
||||||
|
id="ryzen-8845h",
|
||||||
|
sku="AMD Ryzen 7 8845H",
|
||||||
|
physical_cores=8,
|
||||||
|
logical_cores=16,
|
||||||
|
)
|
||||||
|
for cell in ryzen["cells"]:
|
||||||
|
cell["stage"] = "ryzen"
|
||||||
|
cell["inference"]["intra_op_threads"] = 8
|
||||||
|
return archive_path, ryzen, files["manifest.json"]
|
||||||
|
|
||||||
|
|
||||||
def _guard(**changes) -> dict[str, object]:
|
def _guard(**changes) -> dict[str, object]:
|
||||||
result = {
|
result = {
|
||||||
"supply": "ac",
|
"supply": "ac",
|
||||||
@@ -322,6 +436,134 @@ def test_portable_handoff_check_skips_only_machine_identity(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ryzen_manifest_preserves_intel_capsule_inputs_and_evidence(tmp_path):
|
||||||
|
intel = _finalized_handoff_manifest(tmp_path)
|
||||||
|
experiment.validate_manifest(intel, verify_files=False)
|
||||||
|
ryzen = copy.deepcopy(intel)
|
||||||
|
ryzen["experiment"].update(
|
||||||
|
id="cpu-diarization-ryzen",
|
||||||
|
stage="ryzen",
|
||||||
|
handoff_commit="6" * 40,
|
||||||
|
)
|
||||||
|
ryzen["machine"].update(
|
||||||
|
id="ryzen-8845h",
|
||||||
|
sku="AMD Ryzen 7 8845H",
|
||||||
|
physical_cores=8,
|
||||||
|
logical_cores=16,
|
||||||
|
)
|
||||||
|
for cell in ryzen["cells"]:
|
||||||
|
cell["stage"] = "ryzen"
|
||||||
|
cell["inference"]["intra_op_threads"] = 8
|
||||||
|
ryzen["cells"][0]["name"] = "R-W0-primary"
|
||||||
|
ryzen["cells"][1]["name"] = "R-C2-primary"
|
||||||
|
|
||||||
|
experiment.validate_manifest(ryzen, verify_files=False)
|
||||||
|
experiment._validate_ryzen_capsule_manifest(ryzen, intel)
|
||||||
|
|
||||||
|
changed_input = copy.deepcopy(ryzen)
|
||||||
|
changed_input["artifacts"][0]["sha256"] = "7" * 64
|
||||||
|
with pytest.raises(ValueError, match="закрепленные входы"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_input, intel)
|
||||||
|
|
||||||
|
changed_handoff = copy.deepcopy(ryzen)
|
||||||
|
changed_handoff["handoff"]["public_report_sha256"] = "8" * 64
|
||||||
|
with pytest.raises(ValueError, match="handoff"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_handoff, intel)
|
||||||
|
|
||||||
|
changed_recipe = copy.deepcopy(ryzen)
|
||||||
|
changed_recipe["cells"][1]["window_shift_ratio"] = 0.2
|
||||||
|
with pytest.raises(ValueError, match="рецептом handoff"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_recipe, intel)
|
||||||
|
|
||||||
|
changed_candidate = copy.deepcopy(intel)
|
||||||
|
changed_candidate["handoff"]["candidate_recipes"][0]["cell_id"] = "8" * 64
|
||||||
|
changed_candidate_ryzen = copy.deepcopy(ryzen)
|
||||||
|
changed_candidate_ryzen["handoff"]["candidate_recipes"][0]["cell_id"] = (
|
||||||
|
"8" * 64
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Intel combination"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(
|
||||||
|
changed_candidate_ryzen, changed_candidate
|
||||||
|
)
|
||||||
|
|
||||||
|
changed_baseline = copy.deepcopy(ryzen)
|
||||||
|
changed_baseline["cells"][0]["window_shift_ratio"] = 0.5
|
||||||
|
with pytest.raises(ValueError, match="baseline"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_baseline, intel)
|
||||||
|
|
||||||
|
changed_phase = copy.deepcopy(ryzen)
|
||||||
|
changed_phase["cells"][1]["phase"] = "mechanism"
|
||||||
|
changed_phase["cells"][1]["window_shift_ratio"] = 0.5
|
||||||
|
with pytest.raises(ValueError, match="механизмом handoff"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_phase, intel)
|
||||||
|
|
||||||
|
changed_patch = copy.deepcopy(ryzen)
|
||||||
|
changed_patch["experiment"]["sherpa_patch_sha256"] = "7" * 64
|
||||||
|
with pytest.raises(ValueError, match="runtime"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_patch, intel)
|
||||||
|
|
||||||
|
changed_dependencies = copy.deepcopy(ryzen)
|
||||||
|
changed_dependencies["experiment"]["dependencies"]["onnxruntime"] = "1.99.0"
|
||||||
|
with pytest.raises(ValueError, match="runtime"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_dependencies, intel)
|
||||||
|
|
||||||
|
changed_rss_interval = copy.deepcopy(ryzen)
|
||||||
|
changed_rss_interval["rss_sample_interval_ms"] = 5000
|
||||||
|
with pytest.raises(ValueError, match="RSS"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(changed_rss_interval, intel)
|
||||||
|
|
||||||
|
literal_workers_intel = copy.deepcopy(intel)
|
||||||
|
literal_workers_intel["cells"][1]["inference"]["outer_workers"] = 4
|
||||||
|
literal_workers_intel["handoff"]["candidate_recipes"][0]["cell_id"] = (
|
||||||
|
experiment.make_cell_id(
|
||||||
|
literal_workers_intel, literal_workers_intel["cells"][1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
literal_workers_ryzen = copy.deepcopy(ryzen)
|
||||||
|
literal_workers_ryzen["handoff"] = copy.deepcopy(
|
||||||
|
literal_workers_intel["handoff"]
|
||||||
|
)
|
||||||
|
literal_workers_ryzen["cells"][1]["inference"].update(
|
||||||
|
outer_workers=4,
|
||||||
|
intra_op_threads=4,
|
||||||
|
)
|
||||||
|
experiment._validate_ryzen_capsule_manifest(
|
||||||
|
literal_workers_ryzen, literal_workers_intel
|
||||||
|
)
|
||||||
|
scaled_workers_ryzen = copy.deepcopy(literal_workers_ryzen)
|
||||||
|
scaled_workers_ryzen["cells"][1]["inference"].update(
|
||||||
|
outer_workers=8,
|
||||||
|
intra_op_threads=8,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="рецептом handoff"):
|
||||||
|
experiment._validate_ryzen_capsule_manifest(
|
||||||
|
scaled_workers_ryzen, literal_workers_intel
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cell_id", [None, "not-a-sha256"])
|
||||||
|
def test_ryzen_manifest_rejects_invalid_handoff_cell_id(tmp_path, cell_id):
|
||||||
|
manifest = _finalized_handoff_manifest(tmp_path)
|
||||||
|
manifest["experiment"].update(stage="ryzen", handoff_commit="6" * 40)
|
||||||
|
for cell in manifest["cells"]:
|
||||||
|
cell["stage"] = "ryzen"
|
||||||
|
manifest["handoff"]["candidate_recipes"][0]["cell_id"] = cell_id
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="cell_id"):
|
||||||
|
experiment.validate_manifest(manifest, verify_files=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_accepts_unavailable_temperature_sensor(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
manifest["machine"]["power"]["temperature_celsius"] = None
|
||||||
|
|
||||||
|
experiment.validate_manifest(manifest, verify_files=False)
|
||||||
|
|
||||||
|
manifest["machine"]["power"]["temperature_celsius"] = -1.0
|
||||||
|
with pytest.raises(ValueError, match="temperature_celsius"):
|
||||||
|
experiment.validate_manifest(manifest, verify_files=False)
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_rejects_stage_boundary_thread_budget_and_non_alternating_ab(tmp_path):
|
def test_manifest_rejects_stage_boundary_thread_budget_and_non_alternating_ab(tmp_path):
|
||||||
manifest = _manifest(tmp_path)
|
manifest = _manifest(tmp_path)
|
||||||
manifest["cells"][0]["stage"] = "ryzen"
|
manifest["cells"][0]["stage"] = "ryzen"
|
||||||
@@ -808,6 +1050,20 @@ def test_capsule_verification_checks_hashes_commits_and_safe_paths(tmp_path):
|
|||||||
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
|
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="ровно один"):
|
||||||
|
experiment.verify_capsule(
|
||||||
|
archive_path,
|
||||||
|
archive_hash,
|
||||||
|
expected_manifest_path=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="ровно один"):
|
||||||
|
experiment.verify_capsule(
|
||||||
|
archive_path,
|
||||||
|
archive_hash,
|
||||||
|
expected_manifest_path=manifest_path,
|
||||||
|
expected_ryzen_manifest=manifest_value,
|
||||||
|
)
|
||||||
|
|
||||||
incomplete_capsule = copy.deepcopy(capsule)
|
incomplete_capsule = copy.deepcopy(capsule)
|
||||||
incomplete_capsule["files"].pop("data/payload.bin")
|
incomplete_capsule["files"].pop("data/payload.bin")
|
||||||
incomplete_path = tmp_path / "incomplete.zip"
|
incomplete_path = tmp_path / "incomplete.zip"
|
||||||
@@ -843,6 +1099,44 @@ def test_capsule_verification_checks_hashes_commits_and_safe_paths(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_accepts_verified_ryzen_capsule_manifest(
|
||||||
|
tmp_path, capsys, monkeypatch
|
||||||
|
):
|
||||||
|
archive_path, ryzen, intel_manifest_sha256 = _ryzen_capsule_fixture(tmp_path)
|
||||||
|
manifest_path = tmp_path / "ryzen.json"
|
||||||
|
manifest_path.write_text(json.dumps(ryzen), encoding="utf-8")
|
||||||
|
args = SimpleNamespace(
|
||||||
|
manifest=manifest_path,
|
||||||
|
output=None,
|
||||||
|
work_dir=tmp_path / "work",
|
||||||
|
capsule=archive_path,
|
||||||
|
capsule_sha256=experiment.file_sha256(archive_path),
|
||||||
|
validate_only=True,
|
||||||
|
prepare_asr=None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(experiment, "environment_snapshot", _environment)
|
||||||
|
|
||||||
|
experiment.run_cli(args, ryzen)
|
||||||
|
|
||||||
|
plan = json.loads(capsys.readouterr().out)
|
||||||
|
assert plan["stage"] == "ryzen"
|
||||||
|
assert plan["capsule"]["manifest_sha256"] == intel_manifest_sha256
|
||||||
|
assert plan["capsule"]["file_count"] == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_capsule_rejects_unlinked_ryzen_manifest(tmp_path):
|
||||||
|
archive_path, ryzen, _manifest_sha256 = _ryzen_capsule_fixture(tmp_path)
|
||||||
|
ryzen["artifacts"][0]["sha256"] = "7" * 64
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="закрепленные входы"):
|
||||||
|
experiment.verify_capsule(
|
||||||
|
archive_path,
|
||||||
|
experiment.file_sha256(archive_path),
|
||||||
|
expected_manifest_path=None,
|
||||||
|
expected_ryzen_manifest=ryzen,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_capsule_binds_candidate_result_evidence_to_file_index(tmp_path):
|
def test_capsule_binds_candidate_result_evidence_to_file_index(tmp_path):
|
||||||
input_bytes = b"input"
|
input_bytes = b"input"
|
||||||
input_hash = hashlib.sha256(input_bytes).hexdigest()
|
input_hash = hashlib.sha256(input_bytes).hexdigest()
|
||||||
|
|||||||
Reference in new issue
Block a user