From c098fd31e28e0e2a8b093c21fcade7902d3a3cbd Mon Sep 17 00:00:00 2001 From: Andrey K Date: Tue, 22 Sep 2026 11:29:04 +0300 Subject: [PATCH 1/6] Make pipeline_v2's audio length cap configurable `Pipeline::run` rejected any input over one hour against the hard-coded `MAX_AUDIO_SAMPLES`. That constant documents itself as a guard so the C FFI and the Python bindings cannot unbounded-allocate on untrusted buffers, but it was enforced on the Rust pipeline API too, where the caller often produced the audio itself and knows its length is bounded. Such a caller had no way to diarize a two-hour recording: the constant is read directly at the check site, `Pipeline`'s stage fields are private, and splitting the input into sub-hour chunks would break recording-wide clustering. Move the limit into `PipelineConfig::max_audio_samples`, defaulting to `MAX_AUDIO_SAMPLES`, with a matching builder setter. This mirrors the legacy pipeline, which already exposes `max_duration_secs` on its own config. Existing callers are unaffected: the default is unchanged, and the C FFI and WAV loader keep enforcing the constant directly, so the untrusted-input guard stays where its documentation says it is. --- src/pipeline_v2/builder.rs | 10 +++++++++ src/pipeline_v2/config.rs | 9 ++++++++ src/pipeline_v2/mod.rs | 10 ++++++--- src/pipeline_v2/run_tests.rs | 43 ++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/pipeline_v2/builder.rs b/src/pipeline_v2/builder.rs index d7a9b5a..9b092b4 100644 --- a/src/pipeline_v2/builder.rs +++ b/src/pipeline_v2/builder.rs @@ -112,6 +112,16 @@ impl PipelineBuilder { self } + /// Set the maximum PCM length `run` accepts, in samples. + /// + /// Defaults to [`MAX_AUDIO_SAMPLES`](crate::pipeline_v2::MAX_AUDIO_SAMPLES), + /// one hour at 16 kHz. Raise it to diarize longer recordings whose + /// provenance the caller controls. + pub fn max_audio_samples(mut self, samples: usize) -> Self { + self.config.max_audio_samples = samples; + self + } + /// Override the execution provider (defaults to /// `ExecutionProvider::auto()` via `PipelineConfig::default`). pub fn execution_provider(mut self, ep: crate::pipeline_v2::ExecutionProvider) -> Self { diff --git a/src/pipeline_v2/config.rs b/src/pipeline_v2/config.rs index 81c378e..a5ea07c 100644 --- a/src/pipeline_v2/config.rs +++ b/src/pipeline_v2/config.rs @@ -25,6 +25,14 @@ pub struct PipelineConfig { /// seconds (cVBx Δ=0.5 s default). One global value — never per-dataset. pub max_gap_secs: f32, pub embedder_pool_size: usize, + /// Maximum PCM length `Pipeline::run` accepts, in samples. + /// + /// Defaults to [`MAX_AUDIO_SAMPLES`](crate::pipeline_v2::MAX_AUDIO_SAMPLES), + /// one hour at 16 kHz. That default guards callers who hand the pipeline a + /// buffer they did not produce; a caller that controls the audio's + /// provenance, such as one diarizing a file it recorded itself, can raise + /// it. + pub max_audio_samples: usize, pub execution_provider: ExecutionProvider, /// Directory with the precomputed VBx PLDA params, used only when /// `clusterer == ClustererKind::Vbx`. `None` resolves through the @@ -84,6 +92,7 @@ impl Default for PipelineConfig { min_speech_secs: 0.25, max_gap_secs: 0.5, embedder_pool_size: default_pool_size(), + max_audio_samples: crate::pipeline_v2::MAX_AUDIO_SAMPLES, execution_provider: ExecutionProvider::auto(), vbx_plda_dir: None, embed_window_secs: None, diff --git a/src/pipeline_v2/mod.rs b/src/pipeline_v2/mod.rs index 5d358ab..a6ea584 100644 --- a/src/pipeline_v2/mod.rs +++ b/src/pipeline_v2/mod.rs @@ -183,9 +183,13 @@ fn window_confidence_sum( (sum, n) } -/// Hard cap on PCM length accepted by [`Pipeline::run`] / [`Pipeline::run_with_timings`]. +/// Default cap on PCM length accepted by [`Pipeline::run`] / [`Pipeline::run_with_timings`]. /// Matches the C FFI (`MAX_SAMPLES`) and the WAV loader's ~1-hour policy so library /// and Python callers cannot unbounded-allocate on untrusted buffers. +/// +/// This is the default of [`PipelineConfig::max_audio_samples`], which a Rust +/// caller can raise when it controls the audio's provenance. The C FFI and the +/// WAV loader keep enforcing this constant directly. pub const MAX_AUDIO_SAMPLES: usize = 16_000 * 3_600; #[derive(Debug, thiserror::Error)] @@ -273,10 +277,10 @@ impl Pipeline { if sr.get() != self.config.sample_rate.get() { return Err(PipelineError::UnsupportedSampleRate { actual: sr.get() }); } - if samples.len() > MAX_AUDIO_SAMPLES { + if samples.len() > self.config.max_audio_samples { return Err(PipelineError::AudioTooLong { actual_samples: samples.len(), - max_samples: MAX_AUDIO_SAMPLES, + max_samples: self.config.max_audio_samples, }); } let mut timings = StageTimings::default(); diff --git a/src/pipeline_v2/run_tests.rs b/src/pipeline_v2/run_tests.rs index cbad745..cdb6b47 100644 --- a/src/pipeline_v2/run_tests.rs +++ b/src/pipeline_v2/run_tests.rs @@ -130,6 +130,49 @@ fn max_audio_samples_matches_one_hour_at_16khz() { assert_eq!(MAX_AUDIO_SAMPLES, 16_000 * 3_600); } +#[test] +fn default_config_keeps_the_one_hour_cap() { + assert_eq!( + PipelineConfig::default().max_audio_samples, + MAX_AUDIO_SAMPLES + ); +} + +#[test] +fn configured_cap_replaces_the_default() { + // A lowered cap proves `run` consults the config rather than the constant, + // without allocating an hour of audio to prove the same thing upward. + let cfg = PipelineConfig { + profile: Profile::Custom, + max_audio_samples: 100, + ..PipelineConfig::default() + }; + let p = Pipeline::from_components( + cfg, + Box::new(MockSegmenter { + segments: Vec::new(), + }), + Box::new(MockEmbedder::default()), + Box::new(MockClusterer::default()), + Box::new(OverlapResegmenter::default()), + ); + + let err = p + .run(&vec![0.0_f32; 101], SampleRate::new(16000).unwrap()) + .unwrap_err(); + + assert!( + matches!( + err, + PipelineError::AudioTooLong { + actual_samples: 101, + max_samples: 100, + } + ), + "expected the configured cap to be reported, got {err:?}" + ); +} + #[test] fn pipeline_run_silence_returns_empty() { let p = pipeline_with_segments(Vec::new()); From dc6c95135a7eb546520e83542103c83e6523e86f Mon Sep 17 00:00:00 2001 From: Evgeny Khodzitsky <45710942+ekhodzitsky@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:43:55 +0300 Subject: [PATCH 2/6] fix: restore FFI and local pipeline feature builds --- .github/workflows/ci.yml | 4 ++++ CHANGELOG.md | 6 ++++++ src/ffi/mod.rs | 1 + src/models/mod.rs | 1 + src/pipeline_v2/builder_tests.rs | 6 ++---- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca66e11..6d4252f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -479,6 +479,10 @@ jobs: - run: cargo test --no-default-features --features clusterer,vbx --lib --locked - run: cargo test --no-default-features --features clusterer,vbx --test library_vbx_from_dir --locked - run: cargo clippy --no-default-features --features clusterer,vbx --all-targets --locked -- -D warnings + # Local model loading must also compile its tests without download. + # Serial execution avoids races between process-environment tests. + - run: cargo test --no-default-features --features pipeline-local,vbx --lib --locked -- --test-threads=1 + - run: cargo clippy --no-default-features --features pipeline-local,vbx --lib --tests --locked -- -D warnings # Hand-written kernels + kernel-only CLI (no ort, no tract). - run: cargo test -p polyvoice-kernels --locked - run: cargo clippy -p polyvoice-kernels --all-targets --locked -- -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b858d..4e86ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Restore FFI compilation after configuration validation was added, mapping + invalid settings to `InvalidArg`. Local-pipeline tests now compile without + the optional downloader and are covered by CI. + ### Added - `pipeline-local`: powerset + ResNet34 kernels + VBx from a local directory diff --git a/src/ffi/mod.rs b/src/ffi/mod.rs index 4e75930..02f23a2 100644 --- a/src/ffi/mod.rs +++ b/src/ffi/mod.rs @@ -155,6 +155,7 @@ polyvoice_pipeline_create( crate::pipeline_v2::ConfigError::Load { .. } => { PolyvoiceStatus::ModelLoad as c_int } + crate::pipeline_v2::ConfigError::InvalidSetting { .. } | crate::pipeline_v2::ConfigError::MissingRegistry { .. } | crate::pipeline_v2::ConfigError::CustomComponentInProfile { .. } | crate::pipeline_v2::ConfigError::RegistryInCustomProfile | diff --git a/src/models/mod.rs b/src/models/mod.rs index 37316ee..2f1f839 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -621,6 +621,7 @@ mod tests { assert_eq!(fast.embedder, bal.embedder); } + #[cfg(feature = "download")] #[test] fn registry_default_uses_user_cache() { let r = ModelRegistry::default().expect("default cache dir resolvable"); diff --git a/src/pipeline_v2/builder_tests.rs b/src/pipeline_v2/builder_tests.rs index 361015c..fbed134 100644 --- a/src/pipeline_v2/builder_tests.rs +++ b/src/pipeline_v2/builder_tests.rs @@ -339,10 +339,8 @@ fn validate_balanced_with_custom_segmenter_errors() { #[test] fn validate_custom_with_registry_errors() { - let registry = match ModelRegistry::default() { - Ok(r) => r, - Err(_) => return, - }; + let tmp = tempfile::TempDir::new().expect("temp dir"); + let registry = ModelRegistry::with_local_dir(tmp.path()).expect("local registry"); let b = fresh() .profile(Profile::Custom) .with_segmenter(Box::new(MockSegmenter::default())) From d2dc59c310c0d73d85a5472d792844b68220bbec Mon Sep 17 00:00:00 2001 From: Evgeny Khodzitsky <45710942+ekhodzitsky@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:18:22 +0300 Subject: [PATCH 3/6] fix: enforce locked dependency checks and Python wheel builds --- .github/workflows/ci.yml | 1 + .github/workflows/python-wheels.yml | 2 +- .github/workflows/python.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 6 +++ python/Cargo.lock | 10 ++++ scripts/check-ort-free.sh | 20 ++------ scripts/check-ort-version.sh | 4 +- scripts/check-zero-deps.sh | 27 +++++----- scripts/test-dependency-checks.py | 77 +++++++++++++++++++++++++++++ 10 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 scripts/test-dependency-checks.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca66e11..cd92737 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - run: python3 scripts/test-dependency-checks.py - run: bash scripts/check-standalone-lockfiles.sh fmt: diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index aa337ee..718de91 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -33,7 +33,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} - args: --release --out dist + args: --locked --release --out dist sccache: ${{ matrix.os != 'macos-latest' }} manylinux: ${{ matrix.manylinux || 'auto' }} working-directory: python diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 725868e..d58db1e 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python }} - run: pip install maturin pytest - - run: maturin build --release --out dist + - run: maturin build --locked --release --out dist - name: Wheel has no ONNX Runtime dylib shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e599d9a..5443923 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,7 +164,7 @@ jobs: ~/AppData/Local/ort.pyke.io key: ort-dfbin-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - run: pip install maturin - - run: maturin build --release --out dist + - run: maturin build --locked --release --out dist - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: wheels-${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b858d..c26f1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Refresh the Python lockfile for mmap-backed kernels. Dependency checks and + wheel builds enforce locked resolution; Cargo failures no longer pass as + evidence that a forbidden dependency is absent. + ### Added - `pipeline-local`: powerset + ResNet34 kernels + VBx from a local directory diff --git a/python/Cargo.lock b/python/Cargo.lock index 56a6537..f9b5d64 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -246,6 +246,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "minisign-verify" version = "0.2.5" @@ -349,6 +358,7 @@ name = "polyvoice-kernels" version = "0.1.2" dependencies = [ "cc", + "memmap2", "pkg-config", "rten-gemm", "rten-tensor", diff --git a/scripts/check-ort-free.sh b/scripts/check-ort-free.sh index f6a2544..cfd4b3f 100755 --- a/scripts/check-ort-free.sh +++ b/scripts/check-ort-free.sh @@ -11,26 +11,14 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -# Returns 0 if `ort` appears in the normal dependency graph for the given -# cargo-tree args; 1 if cargo cannot resolve package ID `ort` (clean). -ort_in_graph() { - # Redirect stderr: when clean, cargo prints "package ID specification `ort` - # did not match any packages" and exits non-zero. We only care whether any - # package lines land on stdout. - # Portable grep (CI images may not have ripgrep). Any stdout line means - # cargo resolved an `ort` package into the normal graph. - if cargo tree -e normal -i ort "$@" 2>/dev/null | grep -q .; then - return 0 - fi - return 1 -} - fail_if_ort() { local label="$1" shift - if ort_in_graph "$@"; then + local graph + graph="$(cargo tree --locked -e normal --prefix none "$@")" || exit 1 + if grep -q '^ort v' <<< "$graph"; then echo "FAIL: ort leaked into ${label} dependency graph:" - cargo tree -e normal -i ort "$@" || true + printf '%s\n' "$graph" exit 1 fi echo "OK: no ort in ${label}" diff --git a/scripts/check-ort-version.sh b/scripts/check-ort-version.sh index cc19269..06f3a7c 100755 --- a/scripts/check-ort-version.sh +++ b/scripts/check-ort-version.sh @@ -11,8 +11,8 @@ EXPECTED="2.0.0-rc.12" ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -versions="$(cargo metadata --format-version 1 2>/dev/null \ - | python3 -c "import sys,json; print('\n'.join(sorted({p['version'] for p in json.load(sys.stdin)['packages'] if p['name']=='ort'})))")" +metadata="$(cargo metadata --locked --format-version 1)" +versions="$(python3 -c "import sys,json; print('\n'.join(sorted({p['version'] for p in json.load(sys.stdin)['packages'] if p['name']=='ort'})))" <<< "$metadata")" count="$(printf '%s\n' "$versions" | grep -c . || true)" diff --git a/scripts/check-zero-deps.sh b/scripts/check-zero-deps.sh index 3c34e0f..d5a6a1c 100755 --- a/scripts/check-zero-deps.sh +++ b/scripts/check-zero-deps.sh @@ -17,22 +17,15 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -pkg_in_graph() { - local pkg="$1" - shift - if cargo tree -e normal -i "$pkg" "$@" 2>/dev/null | grep -q .; then - return 0 - fi - return 1 -} - fail_if_pkg() { local pkg="$1" local label="$2" shift 2 - if pkg_in_graph "$pkg" "$@"; then + local graph + graph="$(cargo tree --locked -e normal --prefix none "$@")" || exit 1 + if grep -q "^${pkg} v" <<< "$graph"; then echo "FAIL: ${pkg} leaked into ${label}:" - cargo tree -e normal -i "$pkg" "$@" || true + printf '%s\n' "$graph" exit 1 fi echo "OK: no ${pkg} in ${label}" @@ -42,8 +35,10 @@ require_pkg() { local pkg="$1" local label="$2" shift 2 - if ! pkg_in_graph "$pkg" "$@"; then - echo "FAIL: expected ${pkg} in ${label} but cargo tree -i found nothing" + local graph + graph="$(cargo tree --locked -e normal --prefix none "$@")" || exit 1 + if ! grep -q "^${pkg} v" <<< "$graph"; then + echo "FAIL: expected ${pkg} in ${label} but cargo tree found nothing" exit 1 fi echo "OK: ${pkg} present in ${label}" @@ -88,10 +83,10 @@ fail_if_pkg ort "--all-features" --all-features echo "" echo "=== 3b. polyvoice-asr cli does not enable polyvoice/onnx ===" # Parakeet still depends on ort. The polyvoice package in that graph must not. -if cargo tree -p polyvoice-asr --features cli -e normal -i ort --prefix none 2>/dev/null \ - | grep -q '^polyvoice v'; then +graph="$(cargo tree --locked -p polyvoice-asr --features cli -e normal -i ort --prefix none)" +if grep -q '^polyvoice v' <<< "$graph"; then echo "FAIL: polyvoice depends on ort via polyvoice-asr --features cli:" - cargo tree -p polyvoice-asr --features cli -e normal -i ort || true + printf '%s\n' "$graph" exit 1 fi echo "OK: polyvoice-asr --features cli does not enable polyvoice/onnx" diff --git a/scripts/test-dependency-checks.py b/scripts/test-dependency-checks.py new file mode 100644 index 0000000..4da19b6 --- /dev/null +++ b/scripts/test-dependency-checks.py @@ -0,0 +1,77 @@ +"""Regression checks for dependency gates; no downloads or lockfile writes.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parent.parent + + +class DependencyChecks(unittest.TestCase): + def run_gate(self, script, failure): + with tempfile.TemporaryDirectory() as directory: + cargo = Path(directory) / "cargo" + cargo.write_text("""#!/usr/bin/env python3 +import os, sys +args = ' '.join(sys.argv[1:]) +failure = os.environ['CARGO_TEST_FAILURE'] +if (failure == 'all' + or failure == 'python' and 'python/Cargo.toml' in args + or failure == 'asr' and '-p polyvoice-asr' in args): + print('simulated Cargo resolution failure', file=sys.stderr) + sys.exit(101) +if '--locked' not in sys.argv: + print('missing --locked', file=sys.stderr) + sys.exit(102) +print('polyvoice v0.21.0') +if 'vad-earshot' in args: + print('earshot v1.2.0') +if any(flag in args for flag in ['backend-tract', 'pipeline-tract', 'cli-tract']): + print('tract-onnx v0.23.4') +if any(flag in args for flag in ['native', '--features cli', '--features ffi']): + print('polyvoice-kernels v0.1.2') +if failure == 'leak': + print('ort v2.0.0-rc.12') +""") + cargo.chmod(0o755) + return subprocess.run( + ["bash", str(ROOT / "scripts" / script)], + cwd=ROOT, + env={**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"], + "CARGO_TEST_FAILURE": failure}, + capture_output=True, text=True, + ) + + def test_resolution_failure_is_not_dependency_absence(self): + for script in ["check-ort-free.sh", "check-zero-deps.sh", + "check-standalone-lockfiles.sh", "check-ort-version.sh"]: + with self.subTest(script=script): + result = self.run_gate(script, "all") + self.assertNotEqual(result.returncode, 0) + self.assertIn("simulated Cargo resolution failure", result.stderr) + self.assertNotIn("OK:", result.stdout) + + def test_stale_python_lockfile_fails_visibly(self): + for script in ["check-ort-free.sh", "check-zero-deps.sh", + "check-standalone-lockfiles.sh"]: + with self.subTest(script=script): + result = self.run_gate(script, "python") + self.assertNotEqual(result.returncode, 0) + self.assertIn("simulated Cargo resolution failure", result.stderr) + + def test_asr_resolution_failure_fails_visibly(self): + result = self.run_gate("check-zero-deps.sh", "asr") + self.assertNotEqual(result.returncode, 0) + self.assertIn("simulated Cargo resolution failure", result.stderr) + + def test_forbidden_dependency_is_rejected(self): + result = self.run_gate("check-ort-free.sh", "leak") + self.assertNotEqual(result.returncode, 0) + self.assertIn("FAIL: ort leaked", result.stdout) + + +if __name__ == "__main__": + unittest.main() From 0cf1514f3f5f3612181c2166300c34f64e1081e9 Mon Sep 17 00:00:00 2001 From: Evgeny Khodzitsky <45710942+ekhodzitsky@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:20:20 +0300 Subject: [PATCH 4/6] chore: prepare development version for breaking API changes --- CHANGELOG.md | 10 ++++++++-- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/semver.md | 2 +- fuzz/Cargo.lock | 2 +- polyvoice-asr-sherpa/Cargo.lock | 2 +- python/Cargo.lock | 4 ++-- python/Cargo.toml | 2 +- python/pyproject.toml | 2 +- tests/der_baseline.json | 2 +- 10 files changed, 18 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4da0a..c7f093d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- Development version advances to 0.22.0 for the accumulated API changes. + Exhaustive configuration literals must account for `PipelineConfig.reconstruct` + and `VbxClustererConfig.{ahc_on_raw_l2,soft_reassign}`; exhaustive error matches + must handle `ConfigError::InvalidSetting` and the local registry verification + variants `RegistryError::{ChecksumMismatch,SignatureRejected}`. + - `PipelineConfig` gains `max_audio_samples`. Full struct literals must add - this field or use `..PipelineConfig::default()`. Release this API change - in the next minor version, not a 0.21.x patch. The default remains one hour + this field or use `..PipelineConfig::default()`. This API change is part + of 0.22.0. The default remains one hour at 16 kHz; Rust callers can override it with `PipelineBuilder::max_audio_samples`. C FFI and WAV-loading limits remain unchanged. diff --git a/Cargo.lock b/Cargo.lock index c42cb7c..7695476 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2251,7 +2251,7 @@ dependencies = [ [[package]] name = "polyvoice" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index a841b50..f41ed02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" [package] name = "polyvoice" -version = "0.21.0" +version = "0.22.0" edition = "2024" # MSRV: rten-gemm / rten-simd (Linux INT8 GEMM) need 1.94. rust-version = "1.94.0" diff --git a/docs/semver.md b/docs/semver.md index 1dd747d..48107db 100644 --- a/docs/semver.md +++ b/docs/semver.md @@ -1,6 +1,6 @@ # Semver and API freeze -Crate version is **0.21.x**. This is not `1.0.0`. The GO checklist in +Development version is **0.22.0** (unreleased). This is not `1.0.0`. The GO checklist in [`PRODUCTION-READINESS.md`](../PRODUCTION-READINESS.md) stays open until a freeze window has held and the other 1.0 boxes are true. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 66abd6a..c16576f 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -160,7 +160,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "polyvoice" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "ndarray", diff --git a/polyvoice-asr-sherpa/Cargo.lock b/polyvoice-asr-sherpa/Cargo.lock index 0eb5293..360234c 100644 --- a/polyvoice-asr-sherpa/Cargo.lock +++ b/polyvoice-asr-sherpa/Cargo.lock @@ -617,7 +617,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polyvoice" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "ndarray", diff --git a/python/Cargo.lock b/python/Cargo.lock index f9b5d64..1299280 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -335,7 +335,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "polyvoice" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "dirs", @@ -367,7 +367,7 @@ dependencies = [ [[package]] name = "polyvoice-python" -version = "0.21.0" +version = "0.22.0" dependencies = [ "polyvoice", "pyo3", diff --git a/python/Cargo.toml b/python/Cargo.toml index 7353ef2..aac3369 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "polyvoice-python" -version = "0.21.0" +version = "0.22.0" edition = "2024" # Tracks the core crate's MSRV (the path dependency on polyvoice requires 1.94); # pyo3 itself is fine with older toolchains. diff --git a/python/pyproject.toml b/python/pyproject.toml index be27879..1ab4a0b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "polyvoice" -version = "0.21.0" +version = "0.22.0" description = "Speaker diarization for Python — who spoke when. Hand-written INT8 kernels (~8.4 MB), no ONNX Runtime. VBx clustering, overlap detection." readme = "README.md" license = {text = "MIT"} diff --git a/tests/der_baseline.json b/tests/der_baseline.json index b6171a6..d8ba827 100644 --- a/tests/der_baseline.json +++ b/tests/der_baseline.json @@ -1,6 +1,6 @@ { "schema": "polyvoice-der-baseline-v2", - "crate_version": "0.21.0", + "crate_version": "0.22.0", "git_sha": "2aa1e7030dc796e4f3e04ee1629889e62f908e27", "command_line": "target/release/polyvoice-bench data/ --profile balanced --pipeline v2 --clusterer vbx --collar <0|0.25> --output benchmarks/results/int8-full-der-2026-08-10/.json", "voxconverse_test": { From 1df4c5e48a8f3832f54310a5ae082fccc6e12c9c Mon Sep 17 00:00:00 2001 From: Evgeny Khodzitsky <45710942+ekhodzitsky@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:23:27 +0300 Subject: [PATCH 5/6] test: pass local PLDA fixtures explicitly to native builders --- src/pipeline_v2/builder_tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pipeline_v2/builder_tests.rs b/src/pipeline_v2/builder_tests.rs index fbed134..8ea2918 100644 --- a/src/pipeline_v2/builder_tests.rs +++ b/src/pipeline_v2/builder_tests.rs @@ -599,7 +599,10 @@ fn build_native_with_local_models_succeeds() { } let registry = ModelRegistry::with_cache_dir(&cache).expect("registry"); let p = fresh() - .profile(Profile::Balanced) + .config(PipelineConfig { + vbx_plda_dir: Some(repo_file("fixtures/vbx-plda")), + ..PipelineConfig::default() + }) .with_models_from(registry) .build() .expect("native kernels build from local INT8 models"); @@ -621,7 +624,10 @@ fn native_pipeline_runs_short_sine() { } let registry = ModelRegistry::with_cache_dir(&cache).expect("registry"); let p = fresh() - .profile(Profile::Balanced) + .config(PipelineConfig { + vbx_plda_dir: Some(repo_file("fixtures/vbx-plda")), + ..PipelineConfig::default() + }) .with_models_from(registry) .build() .expect("build"); From 1c080eed9e77d39833ddfd6f376274d1f36b747b Mon Sep 17 00:00:00 2001 From: Evgeny Khodzitsky <45710942+ekhodzitsky@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:25:58 +0300 Subject: [PATCH 6/6] fix: invalidate packed weights when native models are dropped --- CHANGELOG.md | 3 ++ polyvoice-kernels/src/powerset.rs | 6 +++ polyvoice-kernels/src/resnet34.rs | 6 +++ polyvoice-kernels/src/rten_matmul.rs | 62 ++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f093d..687ddcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Invalidate thread-local packed GEMM weights when a native model is dropped, + preventing stale weights when subsequent models reuse allocation addresses. + - Refresh the Python lockfile for mmap-backed kernels. Dependency checks and wheel builds enforce locked resolution; Cargo failures no longer pass as evidence that a forbidden dependency is absent. diff --git a/polyvoice-kernels/src/powerset.rs b/polyvoice-kernels/src/powerset.rs index b5e9b0b..33adebc 100644 --- a/polyvoice-kernels/src/powerset.rs +++ b/polyvoice-kernels/src/powerset.rs @@ -60,6 +60,12 @@ pub struct Powerset { onnx_map: Option, } +impl Drop for Powerset { + fn drop(&mut self) { + crate::rten_matmul::invalidate_packed_weights(); + } +} + impl Powerset { pub fn from_onnx_path(path: &Path) -> Result { crate::rten_matmul::pin_parallelism(); diff --git a/polyvoice-kernels/src/resnet34.rs b/polyvoice-kernels/src/resnet34.rs index 0856ff0..25cda02 100644 --- a/polyvoice-kernels/src/resnet34.rs +++ b/polyvoice-kernels/src/resnet34.rs @@ -373,6 +373,12 @@ pub struct ResNet34 { onnx_map: Option, } +impl Drop for ResNet34 { + fn drop(&mut self) { + crate::rten_matmul::invalidate_packed_weights(); + } +} + impl ResNet34 { /// Load weights from shipping `resnet34_int8.onnx` or the FP32 file /// (initializers only; QDQ weights are dequantized). diff --git a/polyvoice-kernels/src/rten_matmul.rs b/polyvoice-kernels/src/rten_matmul.rs index e6c49e4..b83b5bf 100644 --- a/polyvoice-kernels/src/rten_matmul.rs +++ b/polyvoice-kernels/src/rten_matmul.rs @@ -19,6 +19,29 @@ use rten_tensor::NdTensorView; #[cfg(not(target_vendor = "apple"))] use std::cell::RefCell; +// Packed matrices are keyed by weight address. A model drop invalidates all +// thread-local packs before freed addresses can belong to another model. +#[cfg(not(target_vendor = "apple"))] +static WEIGHT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +pub(crate) fn invalidate_packed_weights() { + #[cfg(not(target_vendor = "apple"))] + WEIGHT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::AcqRel); +} + +#[cfg(not(target_vendor = "apple"))] +fn refresh_packed_weights() { + let generation = WEIGHT_GENERATION.load(std::sync::atomic::Ordering::Acquire); + PACK_GENERATION.with(|seen| { + if seen.get() != generation { + PACKED_F32_A.with(|cache| cache.borrow_mut().clear()); + PACKED_F32_B.with(|cache| cache.borrow_mut().clear()); + PACKED_I8_B.with(|cache| cache.borrow_mut().clear()); + seen.set(generation); + } + }); +} + pub fn pin_parallelism() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { @@ -52,6 +75,7 @@ struct I8Scratch { #[cfg(not(target_vendor = "apple"))] thread_local! { + static PACK_GENERATION: std::cell::Cell = const { std::cell::Cell::new(0) }; static F32_EXEC: GemmExecutor = { pin_parallelism(); GemmExecutor::new() @@ -97,6 +121,7 @@ pub fn gemm_rowbias( n: usize, k: usize, ) -> bool { + refresh_packed_weights(); if m == 0 || n == 0 || k == 0 { return false; } @@ -146,6 +171,7 @@ pub fn gemm_colbias( n: usize, k: usize, ) -> bool { + refresh_packed_weights(); if m == 0 || n == 0 || k == 0 { return false; } @@ -188,6 +214,7 @@ pub fn gemm_colbias( /// `C[m,n] += A[m,k] @ B[k,n]`. #[cfg(not(target_vendor = "apple"))] pub fn gemm_add(a: &[f32], b: &[f32], c: &mut [f32], m: usize, n: usize, k: usize) -> bool { + refresh_packed_weights(); if m == 0 || n == 0 || k == 0 { return false; } @@ -247,6 +274,7 @@ pub fn gemm_i8_static( k: usize, accumulate: bool, ) -> bool { + refresh_packed_weights(); if m == 0 || n == 0 || k == 0 || a_scale.abs() < 1e-12 || !i8_exact_on_this_cpu() { return false; } @@ -897,6 +925,40 @@ fn dequant_nchw(acc: &[i32], conv: &Conv2d, y: &mut Tensor, ni: usize, spatial: #[cfg(all(test, not(target_vendor = "apple")))] mod tests { + #[test] + fn packed_weights_are_invalidated_across_threads() { + let mut weights = vec![1.0f32; 4]; + let input = vec![1.0f32; 4]; + let mut out = vec![0.0f32; 4]; + assert!(super::gemm_colbias( + &input, &weights, &[0.0; 2], &mut out, 2, 2, 2 + )); + assert_eq!(out, vec![2.0; 4]); + // Model destruction on another thread must invalidate this thread's + // packed weights before an allocator reuses the same address. + assert!( + std::thread::spawn(super::invalidate_packed_weights) + .join() + .is_ok() + ); + weights.fill(3.0); + assert!(super::gemm_colbias( + &input, &weights, &[0.0; 2], &mut out, 2, 2, 2 + )); + assert_eq!(out, vec![6.0; 4]); + + assert!(super::gemm_rowbias( + &weights, &input, &[0.0; 2], &mut out, 2, 2, 2 + )); + assert_eq!(out, vec![6.0; 4]); + super::invalidate_packed_weights(); + weights.fill(5.0); + assert!(super::gemm_rowbias( + &weights, &input, &[0.0; 2], &mut out, 2, 2, 2 + )); + assert_eq!(out, vec![10.0; 4]); + } + use super::try_conv_i8; use crate::conv::Conv2d; use crate::tensor::Tensor;