diff --git a/.gitignore b/.gitignore index 49d5592..5b86eab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ dist/ .codex .qwen/ tmp -.scratch/handoffs/ +/.scratch/ diff --git a/.scratch/diarization/README.md b/.scratch/diarization/README.md index ce16722..fb6e555 100644 --- a/.scratch/diarization/README.md +++ b/.scratch/diarization/README.md @@ -5,6 +5,11 @@ Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях проекта, и живут в `.scratch/`, а не в `src/`. +Код этого каталога временно закреплен в Git: его используют несколько задач на +нескольких компьютерах, пока идет исследование диаризации. Общее правило +`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы +этого стенда продолжают версионироваться. + Результаты первого прогона описаны в [разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md). diff --git a/docs/benchmarks/2026-09-04-streaming-sortformer-ryzen.md b/docs/benchmarks/2026-09-04-streaming-sortformer-ryzen.md new file mode 100644 index 0000000..353814d --- /dev/null +++ b/docs/benchmarks/2026-09-04-streaming-sortformer-ryzen.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`. + +Закрытая основа и результаты сохраняются для следующих исследований. Их +повторное использование должно быть явно описано в новой задаче. diff --git a/docs/specs/2026-08-30-cpu-diarization-experiment.md b/docs/specs/2026-08-30-cpu-diarization-experiment.md index 042dde1..ea7ea56 100644 --- a/docs/specs/2026-08-30-cpu-diarization-experiment.md +++ b/docs/specs/2026-08-30-cpu-diarization-experiment.md @@ -151,9 +151,10 @@ За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы -слуховой проверки. Рабочий каталог стенда нельзя размещать в обычной -`.scratch`: репозиторий игнорирует там только служебный каталог -`.scratch/handoffs/`, не связанный с переносом эксперимента между ноутбуками. +слуховой проверки. Рабочий каталог стенда нельзя размещать в `.scratch`: этот +каталог локален и целиком игнорируется, поэтому эксперимент из него не +переносится между ноутбуками. Временно закрепленные диагностические скрипты уже +отслеживаются Git и не меняют этого правила для новых материалов. Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят локальные пути, распознанный текст, временные интервалы, имена участников и diff --git a/docs/specs/2026-09-01-streaming-sortformer-cpu-experiment.md b/docs/specs/2026-09-01-streaming-sortformer-cpu-experiment.md index c8e7a4b..0e6f5fb 100644 --- a/docs/specs/2026-09-01-streaming-sortformer-cpu-experiment.md +++ b/docs/specs/2026-09-01-streaming-sortformer-cpu-experiment.md @@ -186,20 +186,25 @@ mono/16 kHz float32. Публичные документы получают то после единственного закрепленного преобразования, а ни один путь, ID или интервал не попадает в stdout режима `--validate-only`. -### Шаг 2. Создать ASR-sidecar и опорную разметку +### Шаг 2. Подготовить ASR-sidecar и доступную опорную разметку Действующий ASR выполняется один раз для каждой полной записи до измерений. Sidecar хранит канонические слова с временной привязкой и нормализованный текст; fragment-sidecar получается детерминированным срезом той же версии. Затем -человек прослушивает каждый фрагмент целиком, фиксирует ожидаемое число -голосовых кластеров для фрагментов и полных записей и создает опорную разметку, -достаточную для mapped purity и выбора мест прослушивания. Опорная разметка -остается приближенной и не объявляется истиной границ. +человек прослушивает каждый фрагмент целиком и фиксирует ожидаемое число +голосовых кластеров для фрагментов и полных записей. Опорная разметка фрагмента +необязательна: `reference_path` и `reference_sha256` либо заполнены вместе, либо +оба равны `null`. При наличии разметки стенд считает mapped purity и выбирает +места прослушивания; сама разметка остается приближенной и не объявляется +истиной границ. Критерий завершения: для каждого фрагмента и каждой полной записи есть подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один -неизменяемый sidecar и SHA-256; для фрагментов также есть опорная разметка. -Числа старых контролей не копируются. +неизменяемый sidecar и SHA-256. Отсутствие опорной разметки не блокирует +измерение и не ослабляет остальные gates. Для канонического Ryzen-прогона +используются подтвержденные владельцем числа `3 / 2 / 2`; их привязывают к +источникам по хешам, а не по порядку нейтральных имен. Повторное прослушивание +для подсчета голосов не требуется. ### Шаг 3. Пройти пользовательский gate @@ -216,6 +221,10 @@ fragment-sidecar получается детерминированным сре Критерий завершения: в закрытом manifest есть timestamped decision `approved`. Без него исход `needs-user`; стенд не загружает модели. +В каноническом Ryzen-прогоне решением `approved` служит датированный handoff +владельца в комментарии к задаче #46. Нового подтверждения до достижения +30%-го порога не требуется. + ## Измеряемая величина и A/B Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата - @@ -333,9 +342,10 @@ hashes и один pair ID; рассчитаны ускорение, CPU и RSS - каноническая нумерация меток детерминирована на повторном расчете метрик; - все inputs, параметры и output artifacts имеют SHA-256. -Purity и число слов без говорящего сравниваются с новым `W0-FRESH`. Ухудшение -не дает автоматического `fail`, но обязательно добавляет все затронутые места в -слуховой gate. Старые абсолютные значения не участвуют. +Purity для фрагментов с опорной разметкой и число слов без говорящего +сравниваются с новым `W0-FRESH`. Ухудшение не дает автоматического `fail`, но +обязательно добавляет все затронутые места в слуховой gate. Старые абсолютные +значения не участвуют. Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за @@ -353,7 +363,7 @@ Purity и число слов без говорящего сравниваютс - каждая итоговая speaker label принадлежит `1..4`; - число и суммарная длительность интервалов по каждому каналу; - число коротких интервалов, overlap ratio, unmatched words, малые/остаточные - кластеры и mapped purity относительно свежей опорной разметки; + кластеры и mapped purity для фрагментов с опорной разметкой; - wall отдельно для model inference и segment post-processing. Любой неверный shape/value/label или эффективная конфигурация, отличная от @@ -380,17 +390,17 @@ RSS выше 2 ГиБ на машине с 16 ГиБ требует отдель ## Артефакты и приватность Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое -хранилище размещаются вне рабочей копии local-transcriber. Обычная `.scratch` -внутри репозитория не подходит: `.gitignore` покрывает там только -`.scratch/handoffs/`. Ни один runtime/model/build artifact или закрытый вход не -попадает в Git. +хранилище размещаются вне рабочей копии local-transcriber. `.scratch` внутри +репозитория целиком игнорируется, но остается локальной частью конкретного +checkout и не подходит для закрытого переносимого хранилища. Ни один +runtime/model/build artifact или закрытый вход не попадает в Git. Новое закрытое хранилище содержит: - versioned manifest со schema, clean local-transcriber source commit, source/model/runtime/build hashes и расписанием; - исходные ссылки, подготовленные WAV и recipe преобразования; -- ASR-sidecar и опорную разметку; +- ASR-sidecar и имеющуюся опорную разметку; - raw per-fragment results, intervals, word assignments и probability diagnostics; - machine/power/runtime guards и stderr/stdout logs; @@ -458,8 +468,9 @@ privacy scan. Реализация стенда фиксируется чист ## Критерии завершения эксперимента -- [ ] Свежая опора подтверждена владельцем и не наследует старые контрольные - значения. +- [ ] Свежая опора собрана в новом закрытом хранилище; подтвержденные входы, + доступная разметка и числа голосов связаны по хешам, а старые manifest, + расписание и результаты не наследуются. - [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей. - [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и публичном отчете, а `--validate-only` проверяет его до моделей. diff --git a/src/local_transcriber/streaming_sortformer_experiment.py b/src/local_transcriber/streaming_sortformer_experiment.py index 47f7f2b..bfa2575 100644 --- a/src/local_transcriber/streaming_sortformer_experiment.py +++ b/src/local_transcriber/streaming_sortformer_experiment.py @@ -316,7 +316,7 @@ def _resolve(root: Path, value: Any) -> Path: if not isinstance(value, str) or not value: raise PreflightError("unsafe-relative-path") 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") base = root.resolve() resolved = (base / Path(value)).resolve() @@ -509,17 +509,22 @@ def validate_manifest( media_hash = _sha(record["sha256"], "record-hash") sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash") 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_sidecars.append(sidecar_hash) if verify_files: _verify(root, record["path"], media_hash, record["size_bytes"]) _verify(root, record["sidecar_path"], sidecar_hash, None) - if role == "fragment": + if role == "fragment" and reference_hash is not None: _verify( root, - record["reference_path"], - record["reference_sha256"], + reference_path, + reference_hash, None, ) _validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars) @@ -1090,8 +1095,6 @@ def memory_gate( for value in integer_values ): 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 len(rss_after_inputs) != 3: 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: - if before["swap_used_bytes"] > 0: - return True if after is None: return False return bool( - after["swap_used_bytes"] > 0 - or after["swap_sin_bytes"] != before["swap_sin_bytes"] + after["swap_sin_bytes"] != before["swap_sin_bytes"] or after["swap_sout_bytes"] != before["swap_sout_bytes"] ) diff --git a/tests/test_speaker_diarizer.py b/tests/test_speaker_diarizer.py index 589d5d0..e39200b 100644 --- a/tests/test_speaker_diarizer.py +++ b/tests/test_speaker_diarizer.py @@ -222,6 +222,7 @@ def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid( with ( patch("huggingface_hub.cached_assets_path", return_value=tmp_path), + patch.dict(sys.modules, {"sherpa_onnx": SimpleNamespace()}), patch("httpx.stream", return_value=FakeResponse()), pytest.raises(RuntimeError, match="Контрольная сумма"), ): diff --git a/tests/test_streaming_sortformer_experiment.py b/tests/test_streaming_sortformer_experiment.py index 93d15df..daf0dcf 100644 --- a/tests/test_streaming_sortformer_experiment.py +++ b/tests/test_streaming_sortformer_experiment.py @@ -213,6 +213,60 @@ def test_preflight_validates_fresh_exact_schema_before_loading(tmp_path): 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): manifest = _manifest(tmp_path) 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()) +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): manifest = _manifest(tmp_path) 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, rss_after_inputs=[100 * mib, 120 * mib, 121 * mib], machine_ram_bytes=16 * 1024**3, - )["reason"] == "active-swap" + )["reason"] is None assert experiment.memory_gate( stage="fragment", oom=True, @@ -713,6 +786,23 @@ def test_memory_gate_uses_frozen_plateau_and_linear_growth_policy_boundaries(): )["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(): result = experiment.memory_gate( stage="fragment", @@ -1011,7 +1101,7 @@ def test_guarded_cell_accepts_stable_manifest_declared_performance_mode(): 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( [ _guard(), @@ -1031,17 +1121,33 @@ def test_second_guard_invalidation_stops_with_first_reason_and_active_swap_never assert second["reason"] == "new-throttling" 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"}, - runner=lambda _request: calls.append("run") or {"wall_seconds": 1.0}, - guard_reader=lambda: _guard(swap_used_bytes=1), - stabilize=lambda: calls.append("stabilize"), + runner=lambda _request: {"wall_seconds": 1.0}, + guard_reader=lambda: next(snapshots), + stabilize=lambda: None, expected_machine=_machine(), ) - assert swapped["state"] == "stop" - assert swapped["reason"] == "active-swap" - assert calls == [] + + assert result["state"] == "accepted" + assert len(result["attempts"]) == 1 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 == [] -def test_guarded_cell_hashes_result_rejected_by_after_run_swap(): - snapshots = iter([_guard(), _guard(swap_sin_bytes=1)]) +@pytest.mark.parametrize("counter", ["swap_sin_bytes", "swap_sout_bytes"]) +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"} result = experiment.run_guarded_cell( {"semantic_id": "safe-cell", "recipe": "W0-FRESH"},