diff --git a/.gitignore b/.gitignore index d27fe92..49d5592 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ .codex .qwen/ tmp +.scratch/handoffs/ diff --git a/docs/benchmarks/2026-08-30-lightweight-diarization-cpu.md b/docs/benchmarks/2026-08-30-lightweight-diarization-cpu.md index 6cc4749..dc8df3f 100644 --- a/docs/benchmarks/2026-08-30-lightweight-diarization-cpu.md +++ b/docs/benchmarks/2026-08-30-lightweight-diarization-cpu.md @@ -74,6 +74,41 @@ WeSpeaker/TitaNet на трёх пятиминутных русскоязычн Во всех 108 ячейках совпала последовательность 1853 распознанных слов после удаления Unicode-пробелов. Инвариантность текста не нарушена. +## Воспроизведение + +Стенд [`diarization_calibration.py`](../../scripts/benchmarks/diarization_calibration.py) +закрепляет корпус и матрицу задачи #25. Файл manifest, ASR-sidecar, интервалы и +полный результат содержат приватные данные и хранятся отдельно от Git. +Относительные пути в manifest считаются от каталога самого файла, поэтому +приватный набор можно перенести на другую машину целиком. Готовый файл результата +при этом не переносится: продолжение допустимо только на той же платформе, CPU и +том же числе логических ядер. + +Перед дорогим запуском нужно проверить набор без декодирования и моделей: + +```bash +uv run python scripts/benchmarks/diarization_calibration.py \ + --manifest /path/to/manifest.json \ + --validate-only +``` + +Полный запуск и его продолжение используют одну команду и один файл результата: + +```bash +uv run python scripts/benchmarks/diarization_calibration.py \ + --manifest /path/to/manifest.json \ + --output /path/to/results.json \ + --work-dir /path/to/work +``` + +Совместимый файл продолжится с первой незавершенной ячейки. Код возврата 0 +означает, что завершены все автоматические ячейки и ячейки с известным числом +говорящих. Число кластеров и инвариантность текста нужно сверить с условиями +конкретного исследования. Полный контракт аргументов и режим подготовки +ASR-sidecar описаны в `--help`. Результат включает исходный manifest, +приватные пути и интервалы. Поэтому его нельзя добавлять в Git или передавать +внешнему ревьюеру. + ## Оборудование и условия - Intel Core i7-6820HQ, 4 ядра / 8 логических процессоров; diff --git a/docs/research/2026-08-29-lightweight-diarization-models.md b/docs/research/2026-08-29-lightweight-diarization-models.md index dc32924..37849ed 100644 --- a/docs/research/2026-08-29-lightweight-diarization-models.md +++ b/docs/research/2026-08-29-lightweight-diarization-models.md @@ -2,8 +2,8 @@ **Дата:** 2026-08-29 -**Статус:** исследовательская записка. Код, зависимости и рабочая конфигурация -не менялись. +**Статус:** исследовательская записка по публичным источникам. Выводы позднее +проверены локальным CPU-стендом; рабочая конфигурация не менялась. **Метод:** использованы только первичные публичные источники: документация, исходный код и выпуски sherpa-onnx, карточки владельцев моделей и исходная diff --git a/scripts/benchmarks/diarization_calibration.py b/scripts/benchmarks/diarization_calibration.py index a5d5a86..9095f0e 100644 --- a/scripts/benchmarks/diarization_calibration.py +++ b/scripts/benchmarks/diarization_calibration.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import copy import hashlib import importlib.metadata import itertools @@ -24,6 +25,7 @@ from tempfile import NamedTemporaryFile from typing import Any SCHEMA_VERSION = 2 +EXPECTED_SHERPA_ONNX_VERSION = "1.13.5" SAMPLE_RATE = 16_000 SAMPLE_WIDTH_BYTES = 2 CHANNELS = 1 @@ -129,24 +131,73 @@ class PeakRssSampler: def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--manifest", type=Path) - parser.add_argument("--output", type=Path) - parser.add_argument("--work-dir", type=Path) + parser = argparse.ArgumentParser( + description=( + "Проверяет и запускает воспроизводимый CPU-benchmark из issue #25. " + "Приватные пути и результаты остаются вне Git." + ), + epilog=( + "Сначала выполните --validate-only. Полный запуск атомарно сохраняет " + "каждую ячейку и продолжает совместимый output после прерывания." + ), + ) + parser.add_argument( + "--manifest", + type=Path, + help="manifest v2; относительные пути считаются от его каталога", + ) + parser.add_argument( + "--output", + type=Path, + help="приватный JSON результата; существующий совместимый файл продолжится", + ) + parser.add_argument( + "--work-dir", + type=Path, + help="каталог декодированных WAV и запросов дочерним процессам", + ) # Оставлено для воспроизводимости старых команд; значение входит в experiment_id. - parser.add_argument("--threads", type=int) - parser.add_argument("--prepare-asr", metavar="RECORDING_ID") - parser.add_argument("--asr-model") - parser.add_argument("--asr-device") - parser.add_argument("--asr-compute-type") - parser.add_argument("--asr-language") + parser.add_argument( + "--threads", + type=int, + help="число потоков; если задано, должно совпасть с manifest.threads", + ) + parser.add_argument( + "--validate-only", + action="store_true", + help="проверить файлы, корпус и матрицу без декодирования и запуска моделей", + ) + parser.add_argument( + "--prepare-asr", + metavar="RECORDING_ID", + help="создать ASR-sidecar одной записи и вывести его SHA-256", + ) + parser.add_argument("--asr-model", help="production ASR-модель для --prepare-asr") + parser.add_argument("--asr-device", help="устройство ASR для --prepare-asr") + parser.add_argument("--asr-compute-type", help="точность ASR для --prepare-asr") + parser.add_argument("--asr-language", help="язык ASR для --prepare-asr") parser.add_argument("--worker", type=Path, help=argparse.SUPPRESS) args = parser.parse_args(argv) if args.worker is None: - missing = [ - name for name in ("manifest", "work_dir") if getattr(args, name) is None - ] - if args.prepare_asr is None and args.output is None: + validate_only_conflicts = ( + "output", + "work_dir", + "prepare_asr", + "asr_model", + "asr_device", + "asr_compute_type", + "asr_language", + ) + if args.validate_only and any( + getattr(args, name) is not None for name in validate_only_conflicts + ): + parser.error( + "--validate-only нельзя сочетать с параметрами запуска или подготовки ASR" + ) + missing = ["manifest"] if args.manifest is None else [] + if not args.validate_only and args.work_dir is None: + missing.append("work_dir") + if not args.validate_only and args.prepare_asr is None and args.output is None: missing.append("output") if missing: parser.error("обязательные параметры: " + ", ".join(missing)) @@ -166,6 +217,29 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return args +def resolve_manifest_paths( + manifest: dict[str, Any], manifest_dir: Path +) -> dict[str, Any]: + """Разрешает приватные относительные пути от каталога manifest.""" + result = copy.deepcopy(manifest) + base = manifest_dir.resolve() + + def resolve(value: str) -> str: + path = Path(value) + return str(path if path.is_absolute() else (base / path).resolve()) + + for model in result["segmentation_models"]: + model["path"] = resolve(model["path"]) + for model in result["embedding_models"]: + model["path"] = resolve(model["path"]) + for recording in result["recordings"]: + recording["path"] = resolve(recording["path"]) + if recording["reference"] is not None: + recording["reference"]["path"] = resolve(recording["reference"]["path"]) + recording["asr_words"]["path"] = resolve(recording["asr_words"]["path"]) + return result + + def file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: @@ -461,9 +535,31 @@ def environment_snapshot() -> dict[str, Any]: "sherpa_onnx_version": version("sherpa-onnx"), "onnxruntime_version": version("onnxruntime"), "numpy_version": version("numpy"), + "psutil_version": version("psutil"), } +def validate_environment(environment: dict[str, Any]) -> None: + """Отсекает окружение, которое заведомо испортит дорогой прогон.""" + missing = [ + key + for key in ( + "sherpa_onnx_version", + "onnxruntime_version", + "numpy_version", + "psutil_version", + ) + if environment.get(key) == "unavailable" + ] + if missing: + raise RuntimeError(f"Не установлены зависимости benchmark: {missing}") + if environment.get("sherpa_onnx_version") != EXPECTED_SHERPA_ONNX_VERSION: + raise RuntimeError( + "Stage timing поддержан только для sherpa-onnx " + f"{EXPECTED_SHERPA_ONNX_VERSION}" + ) + + def semantic_manifest( manifest: dict[str, Any], environment: dict[str, Any] ) -> dict[str, Any]: @@ -652,7 +748,7 @@ def select_threshold( def parse_stage_timings(stderr: str, sherpa_version: str) -> dict[str, float]: """Строго разбирает недокументированный debug-контракт sherpa 1.13.5.""" - if sherpa_version != "1.13.5": + if sherpa_version != EXPECTED_SHERPA_ONNX_VERSION: raise ValueError( f"Неизвестный формат stage timing sherpa-onnx {sherpa_version}" ) @@ -1411,12 +1507,40 @@ def _prepare_asr(args: argparse.Namespace, manifest: dict[str, Any]) -> None: print(digest) +def validation_summary( + manifest: dict[str, Any], environment: dict[str, Any] +) -> dict[str, Any]: + """Возвращает безопасный план без приватных путей и текста.""" + experiment_id = make_experiment_id(manifest, environment) + return { + "status": "valid", + "experiment_id": experiment_id, + "recordings": [item["id"] for item in manifest["recordings"]], + "combinations": [item["id"] for item in manifest["combinations"]], + "automatic_cells": len(expand_automatic_cells(manifest, experiment_id)), + "known_cells_if_all_selected": len(manifest["recordings"]) + * len(manifest["combinations"]), + "threads": manifest["threads"], + "dependencies": { + key: environment[key] + for key in ( + "sherpa_onnx_version", + "onnxruntime_version", + "numpy_version", + "psutil_version", + ) + }, + } + + def main(argv: list[str] | None = None) -> None: args = parse_args(argv) if args.worker is not None: _worker_main(args.worker) return - manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + raw_manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + validate_manifest(raw_manifest, verify_files=False) + manifest = resolve_manifest_paths(raw_manifest, args.manifest.parent) if args.threads is not None: if args.threads <= 0: raise ValueError("--threads должен быть положительным") @@ -1427,8 +1551,18 @@ def main(argv: list[str] | None = None) -> None: return validate_manifest(manifest) validate_original_corpus(manifest) - args.work_dir.mkdir(parents=True, exist_ok=True) environment = environment_snapshot() + validate_environment(environment) + if args.validate_only: + print( + json.dumps( + validation_summary(manifest, environment), + ensure_ascii=False, + indent=2, + ) + ) + return + args.work_dir.mkdir(parents=True, exist_ok=True) experiment_id = make_experiment_id(manifest, environment) output = _load_or_create_output(args.output, manifest, environment, experiment_id) decoded = { diff --git a/tests/test_diarization_calibration.py b/tests/test_diarization_calibration.py index 60962af..f5f621e 100644 --- a/tests/test_diarization_calibration.py +++ b/tests/test_diarization_calibration.py @@ -140,9 +140,88 @@ def _environment(): "sherpa_onnx_version": "1.13.5", "onnxruntime_version": "1", "numpy_version": "2", + "psutil_version": "7", } +def test_validate_only_requires_only_manifest(): + args = calibration.parse_args(["--manifest", "manifest.json", "--validate-only"]) + + assert args.manifest == Path("manifest.json") + assert args.output is None + assert args.work_dir is None + + +@pytest.mark.parametrize( + "extra", + [ + ["--output", "unused"], + ["--work-dir", "unused"], + ["--prepare-asr", "data-test"], + ["--asr-model", "model"], + ["--asr-device", "cpu"], + ["--asr-compute-type", "float32"], + ["--asr-language", "ru"], + ], +) +def test_validate_only_rejects_unused_run_options(extra): + with pytest.raises(SystemExit): + calibration.parse_args( + ["--manifest", "manifest.json", "--validate-only", *extra] + ) + + +def test_relative_manifest_paths_resolve_from_manifest_directory(tmp_path): + manifest = _manifest(tmp_path) + for model in manifest["segmentation_models"] + manifest["embedding_models"]: + model["path"] = Path(model["path"]).name + for recording in manifest["recordings"]: + recording["path"] = Path(recording["path"]).name + recording["asr_words"]["path"] = Path(recording["asr_words"]["path"]).name + if recording["reference"] is not None: + recording["reference"]["path"] = Path(recording["reference"]["path"]).name + + resolved = calibration.resolve_manifest_paths(manifest, tmp_path) + + assert calibration.validate_manifest(resolved)["schema_version"] == 2 + assert Path(resolved["recordings"][0]["path"]).is_absolute() + + +def test_validate_only_prints_safe_plan(tmp_path, monkeypatch, capsys): + manifest_path = tmp_path / "manifest.json" + manifest = _manifest(tmp_path) + for model in manifest["segmentation_models"] + manifest["embedding_models"]: + model["path"] = Path(model["path"]).name + for recording in manifest["recordings"]: + recording["path"] = Path(recording["path"]).name + recording["asr_words"]["path"] = Path(recording["asr_words"]["path"]).name + if recording["reference"] is not None: + recording["reference"]["path"] = Path(recording["reference"]["path"]).name + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + monkeypatch.setattr(calibration, "validate_original_corpus", lambda manifest: None) + monkeypatch.setattr(calibration, "environment_snapshot", _environment) + + calibration.main(["--manifest", str(manifest_path), "--validate-only"]) + + summary = json.loads(capsys.readouterr().out) + assert summary["status"] == "valid" + assert summary["automatic_cells"] == 15 + assert summary["known_cells_if_all_selected"] == 12 + assert str(tmp_path) not in json.dumps(summary) + + +@pytest.mark.parametrize( + ("key", "value"), + [("sherpa_onnx_version", "1.14.0"), ("psutil_version", "unavailable")], +) +def test_validate_environment_rejects_incompatible_dependencies(key, value): + environment = _environment() + environment[key] = value + + with pytest.raises(RuntimeError): + calibration.validate_environment(environment) + + def _complete_cell(spec, clusters, purity=None, residual=0.0): return { **spec,