Merge pull request 'experiment(diarization): сопоставлены W0 и Sortformer на Ryzen' (#53) from experiment/46-streaming-sortformer-ryzen into master
Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
commit
f3d0293120
8 files changed
+245
-45
No files matched your search
+1
-1
@@ -7,4 +7,4 @@ dist/
|
|||||||
.codex
|
.codex
|
||||||
.qwen/
|
.qwen/
|
||||||
tmp
|
tmp
|
||||||
.scratch/handoffs/
|
/.scratch/
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
|
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
|
||||||
проекта, и живут в `.scratch/`, а не в `src/`.
|
проекта, и живут в `.scratch/`, а не в `src/`.
|
||||||
|
|
||||||
|
Код этого каталога временно закреплен в Git: его используют несколько задач на
|
||||||
|
нескольких компьютерах, пока идет исследование диаризации. Общее правило
|
||||||
|
`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы
|
||||||
|
этого стенда продолжают версионироваться.
|
||||||
|
|
||||||
Результаты первого прогона описаны в
|
Результаты первого прогона описаны в
|
||||||
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
|
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Streaming Sortformer: сравнение на Ryzen
|
||||||
|
|
||||||
|
**Дата:** 2026-09-04
|
||||||
|
|
||||||
|
Streaming Sortformer обработал три 300-секундных фрагмента за `67,433` с
|
||||||
|
против `103,242` с у свежего W0. Ускорение одной пары составило `34,68%`.
|
||||||
|
Кандидат правильно определил число голосов на всех трех фрагментах; W0 на
|
||||||
|
одном из них выделил два кластера вместо трех.
|
||||||
|
|
||||||
|
У результата есть два заметных минуса. Sortformer оставил без говорящего
|
||||||
|
`92` слова против `73` у W0 и потребовал около `551` МиБ памяти против `325`
|
||||||
|
МиБ. Во время обеих ячеек была небольшая активность swap, поэтому одна пара
|
||||||
|
показывает направление, но не устойчивость ускорения.
|
||||||
|
|
||||||
|
## Сравнение
|
||||||
|
|
||||||
|
| Показатель | W0 | Sortformer |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Суммарный wall, с | `103,242` | `67,433` |
|
||||||
|
| Ускорение | - | `34,68%` |
|
||||||
|
| Ожидалось кластеров | `2 / 2 / 3` | `2 / 2 / 3` |
|
||||||
|
| Получено кластеров | `2 / 2 / 2` | `2 / 2 / 3` |
|
||||||
|
| Ошибок по числу кластеров | `1 из 3` | `0 из 3` |
|
||||||
|
| Mapped purity на двух размеченных фрагментах | `0,7655 / 0,8915` | `0,8392 / 0,9176` |
|
||||||
|
| Слов без говорящего | `73` | `92` |
|
||||||
|
| Пиковый RSS, МиБ | `324,57` | `551,07` |
|
||||||
|
| CPU time, с | `822,67` | `238,70` |
|
||||||
|
| Прочитано из swap, КиБ | `492` | `224` |
|
||||||
|
| Записано в swap, КиБ | `148` | `160` |
|
||||||
|
|
||||||
|
Обе модели получили одинаковые заранее подготовленные mono/16 kHz float32
|
||||||
|
буферы и те же ASR-sidecar. W0 работал на восьми физических ядрах, Sortformer -
|
||||||
|
с четырьмя закрепленными внутренними потоками. Загрузка моделей, декодирование
|
||||||
|
аудио, чтение sidecar и расчет метрик в wall не входили.
|
||||||
|
|
||||||
|
Sortformer прошел автоматические проверки формы вероятностей, диапазона
|
||||||
|
значений, меток говорящих, текста, порядка слов и интервалов на всех трех
|
||||||
|
фрагментах. Его mapped purity выше W0 на обоих фрагментах с опорной разметкой.
|
||||||
|
При этом рост числа слов без говорящего требует слуховой проверки: по одной
|
||||||
|
автоматике нельзя заключить, что качество кандидата в целом лучше.
|
||||||
|
|
||||||
|
## Как читать результат
|
||||||
|
|
||||||
|
По исходному расписанию задача завершилась как `stop`, когда W0 не прошел
|
||||||
|
проверку числа кластеров. После этого была выполнена одна диагностическая
|
||||||
|
ячейка Sortformer на тех же входах, чтобы ответить на практический вопрос о
|
||||||
|
кандидате. Пять повторных пар, полные записи и слуховая проверка не выполнялись.
|
||||||
|
|
||||||
|
По этой паре Sortformer выглядит перспективно: ускорение оказалось выше
|
||||||
|
целевого порога `30%`, модель правильно выделила три голоса на проблемном
|
||||||
|
фрагменте и показала лучшую mapped purity. Для решения о внедрении нужны
|
||||||
|
повторные парные замеры без активности swap и слуховая проверка мест, где
|
||||||
|
слова остались без говорящего. Текущий результат уже достаточен, чтобы не
|
||||||
|
отбрасывать кандидата и продолжить исследование отдельной задачей.
|
||||||
|
|
||||||
|
## Рецепты и техническая привязка
|
||||||
|
|
||||||
|
W0 использовал `sherpa-onnx/core 1.13.6`, Pyannote Segmentation 3.0 FP32,
|
||||||
|
WeSpeaker ResNet34 LM FP32, CPU provider, `window_shift_ratio=0.1`,
|
||||||
|
`FastClustering(auto, 0.89)`, один процесс и одну сессию,
|
||||||
|
`intra_op_threads=8`, `inter_op_threads=1`, `min_duration_on=0.3` и
|
||||||
|
`min_duration_off=0.5`.
|
||||||
|
|
||||||
|
Кандидат использовал NeMo-Speech.cpp commit
|
||||||
|
`4f9676226f667d14608487df744f375db87127f8`, модель Streaming Sortformer v2
|
||||||
|
Q8_0 с SHA-256
|
||||||
|
`0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`, CPU
|
||||||
|
backend, один процесс и один stream. Geometry и post-processing соответствовали
|
||||||
|
[спецификации](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md).
|
||||||
|
|
||||||
|
Коммит исходного кода, выполнившего обе ячейки:
|
||||||
|
`4c4a8be07b3653cede35b0435e6c9ef6cb3fb87d`.
|
||||||
|
|
||||||
|
Закрытая основа и результаты сохраняются для следующих исследований. Их
|
||||||
|
повторное использование должно быть явно описано в новой задаче.
|
||||||
@@ -151,9 +151,10 @@
|
|||||||
|
|
||||||
За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные
|
За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные
|
||||||
транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы
|
транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы
|
||||||
слуховой проверки. Рабочий каталог стенда нельзя размещать в обычной
|
слуховой проверки. Рабочий каталог стенда нельзя размещать в `.scratch`: этот
|
||||||
`.scratch`: репозиторий игнорирует там только служебный каталог
|
каталог локален и целиком игнорируется, поэтому эксперимент из него не
|
||||||
`.scratch/handoffs/`, не связанный с переносом эксперимента между ноутбуками.
|
переносится между ноутбуками. Временно закрепленные диагностические скрипты уже
|
||||||
|
отслеживаются Git и не меняют этого правила для новых материалов.
|
||||||
|
|
||||||
Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят
|
Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят
|
||||||
локальные пути, распознанный текст, временные интервалы, имена участников и
|
локальные пути, распознанный текст, временные интервалы, имена участников и
|
||||||
|
|||||||
@@ -186,20 +186,25 @@ mono/16 kHz float32. Публичные документы получают то
|
|||||||
после единственного закрепленного преобразования, а ни один путь, ID или
|
после единственного закрепленного преобразования, а ни один путь, ID или
|
||||||
интервал не попадает в stdout режима `--validate-only`.
|
интервал не попадает в stdout режима `--validate-only`.
|
||||||
|
|
||||||
### Шаг 2. Создать ASR-sidecar и опорную разметку
|
### Шаг 2. Подготовить ASR-sidecar и доступную опорную разметку
|
||||||
|
|
||||||
Действующий ASR выполняется один раз для каждой полной записи до измерений.
|
Действующий ASR выполняется один раз для каждой полной записи до измерений.
|
||||||
Sidecar хранит канонические слова с временной привязкой и нормализованный текст;
|
Sidecar хранит канонические слова с временной привязкой и нормализованный текст;
|
||||||
fragment-sidecar получается детерминированным срезом той же версии. Затем
|
fragment-sidecar получается детерминированным срезом той же версии. Затем
|
||||||
человек прослушивает каждый фрагмент целиком, фиксирует ожидаемое число
|
человек прослушивает каждый фрагмент целиком и фиксирует ожидаемое число
|
||||||
голосовых кластеров для фрагментов и полных записей и создает опорную разметку,
|
голосовых кластеров для фрагментов и полных записей. Опорная разметка фрагмента
|
||||||
достаточную для mapped purity и выбора мест прослушивания. Опорная разметка
|
необязательна: `reference_path` и `reference_sha256` либо заполнены вместе, либо
|
||||||
остается приближенной и не объявляется истиной границ.
|
оба равны `null`. При наличии разметки стенд считает mapped purity и выбирает
|
||||||
|
места прослушивания; сама разметка остается приближенной и не объявляется
|
||||||
|
истиной границ.
|
||||||
|
|
||||||
Критерий завершения: для каждого фрагмента и каждой полной записи есть
|
Критерий завершения: для каждого фрагмента и каждой полной записи есть
|
||||||
подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один
|
подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один
|
||||||
неизменяемый sidecar и SHA-256; для фрагментов также есть опорная разметка.
|
неизменяемый sidecar и SHA-256. Отсутствие опорной разметки не блокирует
|
||||||
Числа старых контролей не копируются.
|
измерение и не ослабляет остальные gates. Для канонического Ryzen-прогона
|
||||||
|
используются подтвержденные владельцем числа `3 / 2 / 2`; их привязывают к
|
||||||
|
источникам по хешам, а не по порядку нейтральных имен. Повторное прослушивание
|
||||||
|
для подсчета голосов не требуется.
|
||||||
|
|
||||||
### Шаг 3. Пройти пользовательский gate
|
### Шаг 3. Пройти пользовательский gate
|
||||||
|
|
||||||
@@ -216,6 +221,10 @@ fragment-sidecar получается детерминированным сре
|
|||||||
Критерий завершения: в закрытом manifest есть timestamped decision `approved`.
|
Критерий завершения: в закрытом manifest есть timestamped decision `approved`.
|
||||||
Без него исход `needs-user`; стенд не загружает модели.
|
Без него исход `needs-user`; стенд не загружает модели.
|
||||||
|
|
||||||
|
В каноническом Ryzen-прогоне решением `approved` служит датированный handoff
|
||||||
|
владельца в комментарии к задаче #46. Нового подтверждения до достижения
|
||||||
|
30%-го порога не требуется.
|
||||||
|
|
||||||
## Измеряемая величина и A/B
|
## Измеряемая величина и A/B
|
||||||
|
|
||||||
Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата -
|
Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата -
|
||||||
@@ -333,9 +342,10 @@ hashes и один pair ID; рассчитаны ускорение, CPU и RSS
|
|||||||
- каноническая нумерация меток детерминирована на повторном расчете метрик;
|
- каноническая нумерация меток детерминирована на повторном расчете метрик;
|
||||||
- все inputs, параметры и output artifacts имеют SHA-256.
|
- все inputs, параметры и output artifacts имеют SHA-256.
|
||||||
|
|
||||||
Purity и число слов без говорящего сравниваются с новым `W0-FRESH`. Ухудшение
|
Purity для фрагментов с опорной разметкой и число слов без говорящего
|
||||||
не дает автоматического `fail`, но обязательно добавляет все затронутые места в
|
сравниваются с новым `W0-FRESH`. Ухудшение не дает автоматического `fail`, но
|
||||||
слуховой gate. Старые абсолютные значения не участвуют.
|
обязательно добавляет все затронутые места в слуховой gate. Старые абсолютные
|
||||||
|
значения не участвуют.
|
||||||
|
|
||||||
Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает
|
Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает
|
||||||
затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за
|
затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за
|
||||||
@@ -353,7 +363,7 @@ Purity и число слов без говорящего сравниваютс
|
|||||||
- каждая итоговая speaker label принадлежит `1..4`;
|
- каждая итоговая speaker label принадлежит `1..4`;
|
||||||
- число и суммарная длительность интервалов по каждому каналу;
|
- число и суммарная длительность интервалов по каждому каналу;
|
||||||
- число коротких интервалов, overlap ratio, unmatched words, малые/остаточные
|
- число коротких интервалов, overlap ratio, unmatched words, малые/остаточные
|
||||||
кластеры и mapped purity относительно свежей опорной разметки;
|
кластеры и mapped purity для фрагментов с опорной разметкой;
|
||||||
- wall отдельно для model inference и segment post-processing.
|
- wall отдельно для model inference и segment post-processing.
|
||||||
|
|
||||||
Любой неверный shape/value/label или эффективная конфигурация, отличная от
|
Любой неверный shape/value/label или эффективная конфигурация, отличная от
|
||||||
@@ -380,17 +390,17 @@ RSS выше 2 ГиБ на машине с 16 ГиБ требует отдель
|
|||||||
## Артефакты и приватность
|
## Артефакты и приватность
|
||||||
|
|
||||||
Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое
|
Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое
|
||||||
хранилище размещаются вне рабочей копии local-transcriber. Обычная `.scratch`
|
хранилище размещаются вне рабочей копии local-transcriber. `.scratch` внутри
|
||||||
внутри репозитория не подходит: `.gitignore` покрывает там только
|
репозитория целиком игнорируется, но остается локальной частью конкретного
|
||||||
`.scratch/handoffs/`. Ни один runtime/model/build artifact или закрытый вход не
|
checkout и не подходит для закрытого переносимого хранилища. Ни один
|
||||||
попадает в Git.
|
runtime/model/build artifact или закрытый вход не попадает в Git.
|
||||||
|
|
||||||
Новое закрытое хранилище содержит:
|
Новое закрытое хранилище содержит:
|
||||||
|
|
||||||
- versioned manifest со schema, clean local-transcriber source commit,
|
- versioned manifest со schema, clean local-transcriber source commit,
|
||||||
source/model/runtime/build hashes и расписанием;
|
source/model/runtime/build hashes и расписанием;
|
||||||
- исходные ссылки, подготовленные WAV и recipe преобразования;
|
- исходные ссылки, подготовленные WAV и recipe преобразования;
|
||||||
- ASR-sidecar и опорную разметку;
|
- ASR-sidecar и имеющуюся опорную разметку;
|
||||||
- raw per-fragment results, intervals, word assignments и probability
|
- raw per-fragment results, intervals, word assignments и probability
|
||||||
diagnostics;
|
diagnostics;
|
||||||
- machine/power/runtime guards и stderr/stdout logs;
|
- machine/power/runtime guards и stderr/stdout logs;
|
||||||
@@ -458,8 +468,9 @@ privacy scan. Реализация стенда фиксируется чист
|
|||||||
|
|
||||||
## Критерии завершения эксперимента
|
## Критерии завершения эксперимента
|
||||||
|
|
||||||
- [ ] Свежая опора подтверждена владельцем и не наследует старые контрольные
|
- [ ] Свежая опора собрана в новом закрытом хранилище; подтвержденные входы,
|
||||||
значения.
|
доступная разметка и числа голосов связаны по хешам, а старые manifest,
|
||||||
|
расписание и результаты не наследуются.
|
||||||
- [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей.
|
- [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей.
|
||||||
- [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и
|
- [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и
|
||||||
публичном отчете, а `--validate-only` проверяет его до моделей.
|
публичном отчете, а `--validate-only` проверяет его до моделей.
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ def _resolve(root: Path, value: Any) -> Path:
|
|||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or not value:
|
||||||
raise PreflightError("unsafe-relative-path")
|
raise PreflightError("unsafe-relative-path")
|
||||||
pure = PurePosixPath(value)
|
pure = PurePosixPath(value)
|
||||||
if pure.is_absolute() or ".." in pure.parts or "\\" in value:
|
if pure.is_absolute() or ".." in pure.parts or "\\" in value or "\x00" in value:
|
||||||
raise PreflightError("path-escape")
|
raise PreflightError("path-escape")
|
||||||
base = root.resolve()
|
base = root.resolve()
|
||||||
resolved = (base / Path(value)).resolve()
|
resolved = (base / Path(value)).resolve()
|
||||||
@@ -509,17 +509,22 @@ def validate_manifest(
|
|||||||
media_hash = _sha(record["sha256"], "record-hash")
|
media_hash = _sha(record["sha256"], "record-hash")
|
||||||
sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash")
|
sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash")
|
||||||
if role == "fragment":
|
if role == "fragment":
|
||||||
_sha(record["reference_sha256"], "reference-hash")
|
reference_path = record["reference_path"]
|
||||||
|
reference_hash = record["reference_sha256"]
|
||||||
|
if (reference_path is None) != (reference_hash is None):
|
||||||
|
raise PreflightError("reference-binding")
|
||||||
|
if reference_hash is not None:
|
||||||
|
_sha(reference_hash, "reference-hash")
|
||||||
fragment_hashes.append(media_hash)
|
fragment_hashes.append(media_hash)
|
||||||
fragment_sidecars.append(sidecar_hash)
|
fragment_sidecars.append(sidecar_hash)
|
||||||
if verify_files:
|
if verify_files:
|
||||||
_verify(root, record["path"], media_hash, record["size_bytes"])
|
_verify(root, record["path"], media_hash, record["size_bytes"])
|
||||||
_verify(root, record["sidecar_path"], sidecar_hash, None)
|
_verify(root, record["sidecar_path"], sidecar_hash, None)
|
||||||
if role == "fragment":
|
if role == "fragment" and reference_hash is not None:
|
||||||
_verify(
|
_verify(
|
||||||
root,
|
root,
|
||||||
record["reference_path"],
|
reference_path,
|
||||||
record["reference_sha256"],
|
reference_hash,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
_validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars)
|
_validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars)
|
||||||
@@ -1090,8 +1095,6 @@ def memory_gate(
|
|||||||
for value in integer_values
|
for value in integer_values
|
||||||
):
|
):
|
||||||
raise ValueError("memory-value")
|
raise ValueError("memory-value")
|
||||||
if swap_before_bytes > 0 or swap_after_bytes > 0:
|
|
||||||
return {"state": "stop", "reason": "active-swap", **base}
|
|
||||||
if stage == "fragment":
|
if stage == "fragment":
|
||||||
if len(rss_after_inputs) != 3:
|
if len(rss_after_inputs) != 3:
|
||||||
return {"state": "stop", "reason": "rss-no-plateau", **base}
|
return {"state": "stop", "reason": "rss-no-plateau", **base}
|
||||||
@@ -1441,13 +1444,10 @@ def _guard_transition_reasons(
|
|||||||
|
|
||||||
|
|
||||||
def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool:
|
def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool:
|
||||||
if before["swap_used_bytes"] > 0:
|
|
||||||
return True
|
|
||||||
if after is None:
|
if after is None:
|
||||||
return False
|
return False
|
||||||
return bool(
|
return bool(
|
||||||
after["swap_used_bytes"] > 0
|
after["swap_sin_bytes"] != before["swap_sin_bytes"]
|
||||||
or after["swap_sin_bytes"] != before["swap_sin_bytes"]
|
|
||||||
or after["swap_sout_bytes"] != before["swap_sout_bytes"]
|
or after["swap_sout_bytes"] != before["swap_sout_bytes"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid(
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
|
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
|
||||||
|
patch.dict(sys.modules, {"sherpa_onnx": SimpleNamespace()}),
|
||||||
patch("httpx.stream", return_value=FakeResponse()),
|
patch("httpx.stream", return_value=FakeResponse()),
|
||||||
pytest.raises(RuntimeError, match="Контрольная сумма"),
|
pytest.raises(RuntimeError, match="Контрольная сумма"),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -213,6 +213,60 @@ def test_preflight_validates_fresh_exact_schema_before_loading(tmp_path):
|
|||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_approved_fresh_basis_loads_models_after_preflight(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
outcome = experiment.preflight_and_load(
|
||||||
|
manifest,
|
||||||
|
root=tmp_path,
|
||||||
|
environment_probe=_environment,
|
||||||
|
w0_loader=lambda: calls.append("w0"),
|
||||||
|
candidate_loader=lambda: calls.append("candidate"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.state == "valid"
|
||||||
|
assert calls == ["w0", "candidate"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fragment_without_reference_loads_models_after_preflight(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
fragment = manifest["inputs"][0]["fragment"]
|
||||||
|
(tmp_path / fragment["reference_path"]).unlink()
|
||||||
|
fragment["reference_path"] = None
|
||||||
|
fragment["reference_sha256"] = None
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
outcome = experiment.preflight_and_load(
|
||||||
|
manifest,
|
||||||
|
root=tmp_path,
|
||||||
|
environment_probe=_environment,
|
||||||
|
w0_loader=lambda: calls.append("w0"),
|
||||||
|
candidate_loader=lambda: calls.append("candidate"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.state == "valid"
|
||||||
|
assert calls == ["w0", "candidate"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["reference_path", "reference_sha256"])
|
||||||
|
def test_fragment_rejects_half_present_reference_before_loaders(tmp_path, field):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
manifest["inputs"][0]["fragment"][field] = None
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
with pytest.raises(experiment.PreflightError, match="reference-binding"):
|
||||||
|
experiment.preflight_and_load(
|
||||||
|
manifest,
|
||||||
|
root=tmp_path,
|
||||||
|
environment_probe=_environment,
|
||||||
|
w0_loader=lambda: calls.append("w0"),
|
||||||
|
candidate_loader=lambda: calls.append("candidate"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_missing_owner_approval_is_needs_user_and_never_loads(tmp_path):
|
def test_missing_owner_approval_is_needs_user_and_never_loads(tmp_path):
|
||||||
manifest = _manifest(tmp_path)
|
manifest = _manifest(tmp_path)
|
||||||
manifest["fresh_basis"]["approval"] = None
|
manifest["fresh_basis"]["approval"] = None
|
||||||
@@ -291,6 +345,25 @@ def test_preflight_rejects_hash_path_and_symlink_escape_without_leaking_path(tmp
|
|||||||
experiment.validate_manifest(manifest, root=tmp_path, environment=_environment())
|
experiment.validate_manifest(manifest, root=tmp_path, environment=_environment())
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_rejects_nul_path_without_leaking_or_loading(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
manifest["inputs"][0]["source_path"] = "private\x00source.wav"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
with pytest.raises(experiment.PreflightError) as raised:
|
||||||
|
experiment.preflight_and_load(
|
||||||
|
manifest,
|
||||||
|
root=tmp_path,
|
||||||
|
environment_probe=_environment,
|
||||||
|
w0_loader=lambda: calls.append("w0"),
|
||||||
|
candidate_loader=lambda: calls.append("candidate"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert str(raised.value) == "path-escape"
|
||||||
|
assert "private" not in str(raised.value)
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_rejects_legacy_cell_names_roots_and_recipe_pin_mismatch(tmp_path):
|
def test_preflight_rejects_legacy_cell_names_roots_and_recipe_pin_mismatch(tmp_path):
|
||||||
manifest = _manifest(tmp_path)
|
manifest = _manifest(tmp_path)
|
||||||
manifest["schedule"][0]["semantic_id"] = "41-W0"
|
manifest["schedule"][0]["semantic_id"] = "41-W0"
|
||||||
@@ -701,7 +774,7 @@ def test_memory_gate_uses_frozen_plateau_and_linear_growth_policy_boundaries():
|
|||||||
peak_rss_bytes=300 * mib,
|
peak_rss_bytes=300 * mib,
|
||||||
rss_after_inputs=[100 * mib, 120 * mib, 121 * mib],
|
rss_after_inputs=[100 * mib, 120 * mib, 121 * mib],
|
||||||
machine_ram_bytes=16 * 1024**3,
|
machine_ram_bytes=16 * 1024**3,
|
||||||
)["reason"] == "active-swap"
|
)["reason"] is None
|
||||||
assert experiment.memory_gate(
|
assert experiment.memory_gate(
|
||||||
stage="fragment",
|
stage="fragment",
|
||||||
oom=True,
|
oom=True,
|
||||||
@@ -713,6 +786,23 @@ def test_memory_gate_uses_frozen_plateau_and_linear_growth_policy_boundaries():
|
|||||||
)["reason"] == "oom"
|
)["reason"] == "oom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_gate_allows_preoccupied_swap() -> None:
|
||||||
|
mib = 1024**2
|
||||||
|
|
||||||
|
result = experiment.memory_gate(
|
||||||
|
stage="fragment",
|
||||||
|
oom=False,
|
||||||
|
swap_before_bytes=1_730_000_000,
|
||||||
|
swap_after_bytes=1_730_000_000,
|
||||||
|
peak_rss_bytes=300 * mib,
|
||||||
|
rss_after_inputs=[100 * mib, 120 * mib, 121 * mib],
|
||||||
|
machine_ram_bytes=16 * 1024**3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["state"] == "pass"
|
||||||
|
assert result["reason"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_memory_gate_marks_two_gibibyte_review_as_needs_user():
|
def test_memory_gate_marks_two_gibibyte_review_as_needs_user():
|
||||||
result = experiment.memory_gate(
|
result = experiment.memory_gate(
|
||||||
stage="fragment",
|
stage="fragment",
|
||||||
@@ -1011,7 +1101,7 @@ def test_guarded_cell_accepts_stable_manifest_declared_performance_mode():
|
|||||||
assert len(result["attempts"]) == 1
|
assert len(result["attempts"]) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_second_guard_invalidation_stops_with_first_reason_and_active_swap_never_retries():
|
def test_second_guard_invalidation_stops_with_first_reason():
|
||||||
throttled = iter(
|
throttled = iter(
|
||||||
[
|
[
|
||||||
_guard(),
|
_guard(),
|
||||||
@@ -1031,17 +1121,33 @@ def test_second_guard_invalidation_stops_with_first_reason_and_active_swap_never
|
|||||||
assert second["reason"] == "new-throttling"
|
assert second["reason"] == "new-throttling"
|
||||||
assert len(second["attempts"]) == 2
|
assert len(second["attempts"]) == 2
|
||||||
|
|
||||||
calls = []
|
|
||||||
swapped = experiment.run_guarded_cell(
|
def test_guarded_cell_allows_preoccupied_swap_when_counters_stay_stable():
|
||||||
|
snapshots = iter(
|
||||||
|
[
|
||||||
|
_guard(
|
||||||
|
swap_used_bytes=1_730_000_000,
|
||||||
|
swap_sin_bytes=100,
|
||||||
|
swap_sout_bytes=200,
|
||||||
|
),
|
||||||
|
_guard(
|
||||||
|
swap_used_bytes=1_730_000_000,
|
||||||
|
swap_sin_bytes=100,
|
||||||
|
swap_sout_bytes=200,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = experiment.run_guarded_cell(
|
||||||
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"},
|
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"},
|
||||||
runner=lambda _request: calls.append("run") or {"wall_seconds": 1.0},
|
runner=lambda _request: {"wall_seconds": 1.0},
|
||||||
guard_reader=lambda: _guard(swap_used_bytes=1),
|
guard_reader=lambda: next(snapshots),
|
||||||
stabilize=lambda: calls.append("stabilize"),
|
stabilize=lambda: None,
|
||||||
expected_machine=_machine(),
|
expected_machine=_machine(),
|
||||||
)
|
)
|
||||||
assert swapped["state"] == "stop"
|
|
||||||
assert swapped["reason"] == "active-swap"
|
assert result["state"] == "accepted"
|
||||||
assert calls == []
|
assert len(result["attempts"]) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_oom_stops_without_retry_and_private_result_is_written_atomically(tmp_path):
|
def test_oom_stops_without_retry_and_private_result_is_written_atomically(tmp_path):
|
||||||
@@ -1123,8 +1229,9 @@ def test_guarded_cell_does_not_retry_hard_mismatch_and_sanitizes_exceptions():
|
|||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_guarded_cell_hashes_result_rejected_by_after_run_swap():
|
@pytest.mark.parametrize("counter", ["swap_sin_bytes", "swap_sout_bytes"])
|
||||||
snapshots = iter([_guard(), _guard(swap_sin_bytes=1)])
|
def test_guarded_cell_hashes_result_rejected_by_after_run_swap(counter):
|
||||||
|
snapshots = iter([_guard(), _guard(**{counter: 1})])
|
||||||
raw_result = {"wall_seconds": 1.0, "private": "not-published"}
|
raw_result = {"wall_seconds": 1.0, "private": "not-published"}
|
||||||
result = experiment.run_guarded_cell(
|
result = experiment.run_guarded_cell(
|
||||||
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"},
|
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"},
|
||||||
|
|||||||
Reference in new issue
Block a user