fuzzgpu 0.1.7__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/Cargo.lock +2 -2
  2. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/PKG-INFO +87 -28
  3. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/README.md +86 -27
  4. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/Cargo.toml +1 -1
  5. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/damerau.rs +3 -1
  6. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/jaro.rs +3 -1
  7. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/levenshtein.rs +5 -1
  8. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/needleman.rs +3 -1
  9. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/simd.rs +19 -6
  10. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-python/Cargo.toml +2 -2
  11. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/Cargo.toml +1 -1
  12. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/pyproject.toml +1 -1
  13. fuzzgpu-0.1.8/python/fuzzgpu/distance/DamerauLevenshtein.py +145 -0
  14. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Jaro.py +3 -3
  15. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/JaroWinkler.py +3 -3
  16. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/fuzz.py +54 -3
  17. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/process.py +21 -14
  18. fuzzgpu-0.1.7/python/fuzzgpu/distance/DamerauLevenshtein.py +0 -43
  19. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/Cargo.toml +0 -0
  20. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/LICENSE +0 -0
  21. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/assets/logo.png +0 -0
  22. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/benches/bench.rs +0 -0
  23. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/fuzz.rs +0 -0
  24. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/gpu.rs +0 -0
  25. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/lib.rs +0 -0
  26. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/damerau.wgsl +0 -0
  27. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/damerau_matrix.wgsl +0 -0
  28. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/jaro.wgsl +0 -0
  29. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/jaro_matrix.wgsl +0 -0
  30. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein.wgsl +0 -0
  31. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_cdist_myers.wgsl +0 -0
  32. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_matrix.wgsl +0 -0
  33. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_myers.wgsl +0 -0
  34. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_short.wgsl +0 -0
  35. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/needleman_affine.wgsl +0 -0
  36. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/needleman_wavefront.wgsl +0 -0
  37. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/differential.proptest-regressions +0 -0
  38. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/differential.rs +0 -0
  39. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/fixtures/broken.wgsl +0 -0
  40. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/kernel_registration.rs +0 -0
  41. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-python/src/lib.rs +0 -0
  42. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/src/lib.rs +0 -0
  43. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/tests/differential_harness.js +0 -0
  44. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/tests/js_api.test.cjs +0 -0
  45. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/__init__.py +0 -0
  46. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/__init__.pyi +0 -0
  47. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Hamming.py +0 -0
  48. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Indel.py +0 -0
  49. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/LCSseq.py +0 -0
  50. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Levenshtein.py +0 -0
  51. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/OSA.py +0 -0
  52. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Postfix.py +0 -0
  53. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Prefix.py +0 -0
  54. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/__init__.py +0 -0
  55. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/__init__.pyi +0 -0
  56. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/_common.py +0 -0
  57. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/fuzz.pyi +0 -0
  58. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/python/fuzzgpu/process.pyi +0 -0
  59. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_api_signatures.py +0 -0
  60. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_basic.py +0 -0
  61. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_concurrency.py +0 -0
  62. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_edge_cases.py +0 -0
  63. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_invariants.py +0 -0
  64. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_out_buffers.py +0 -0
  65. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_rapidfuzz_compat.py +0 -0
  66. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/test_stress.py +0 -0
  67. {fuzzgpu-0.1.7 → fuzzgpu-0.1.8}/tests/wasm_python_differential.py +0 -0
@@ -372,7 +372,7 @@ dependencies = [
372
372
 
373
373
  [[package]]
374
374
  name = "fuzzgpu-core"
375
- version = "0.1.7"
375
+ version = "0.1.8"
376
376
  dependencies = [
377
377
  "bytemuck",
378
378
  "criterion",
@@ -386,7 +386,7 @@ dependencies = [
386
386
 
387
387
  [[package]]
388
388
  name = "fuzzgpu-python"
389
- version = "0.1.7"
389
+ version = "0.1.8"
390
390
  dependencies = [
391
391
  "fuzzgpu-core",
392
392
  "log",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fuzzgpu
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -37,7 +37,7 @@ Project-URL: Repository, https://github.com/kuntal-devrat/fuzzgpu
37
37
 
38
38
  *Cross-platform GPU compute via WebGPU (`wgpu`) & Multi-Core CPU parallelism with Rayon. Zero CUDA dependencies.*
39
39
 
40
- [![PyPI Version](https://img.shields.io/badge/pypi-v0.1.7-blue.svg?style=flat-square)](https://pypi.org/project/fuzzgpu/)
40
+ [![PyPI Version](https://img.shields.io/badge/pypi-v0.1.8-blue.svg?style=flat-square)](https://pypi.org/project/fuzzgpu/)
41
41
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=flat-square)](https://opensource.org/licenses/MIT)
42
42
  [![Rust](https://img.shields.io/badge/rust-1.87+-orange.svg?style=flat-square)](https://www.rust-lang.org)
43
43
  [![Cross Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20WASM-lightgrey.svg?style=flat-square)](https://github.com/kuntal-devrat/fuzzgpu)
@@ -60,28 +60,60 @@ No NVIDIA CUDA drivers or complex toolkits required.
60
60
 
61
61
  ---
62
62
 
63
- ## What's New in v0.1.7
63
+ ## What's New in v0.1.8
64
+
65
+ ### Production hardening
66
+ - **Kernel `get()` panics eliminated** — all four GPU kernels (`GpuLevenshteinKernel`,
67
+ `GpuJaroKernel`, `GpuNeedlemanAffineKernel`, `GpuDamerauKernel`) previously called
68
+ `.unwrap()` on `OnceLock::get()` after initialization, which could panic the Python
69
+ interpreter under rare concurrent races. Replaced with `.ok_or_else(...)` returning
70
+ a proper `FuzzGpuError::NoDevice`.
71
+ - **`debug_assert!` → real release guards** — `MyersPattern::new`, `jaro_bitpar`, `jaro_4way`
72
+ were guarded only by `debug_assert!`. In release builds, calling them with inputs outside
73
+ their contract (non-ASCII or > 64 bytes) would silently produce wrong results. Promoted to
74
+ proper `assert!` with descriptive messages that surface immediately in both debug and release.
75
+
76
+ ### New API surface
77
+ - **`fuzz.cdist`** — pairwise score matrix, mirrors `rapidfuzz.fuzz.cdist`. Delegates to
78
+ `process.cdist` with `ratio` as the default scorer.
79
+ - **`DamerauLevenshtein.editops` / `.opcodes`** — full Lowrance-Wagner traceback returning
80
+ `Editops` / `Opcodes` (insert/delete/replace), completing parity with rapidfuzz's alignment
81
+ API for this module.
82
+ - **`fuzz.__all__`** now includes `partial_ratio_alignment` and `cdist` (were missing).
83
+
84
+ ### API correctness fixes
85
+ - **`Jaro.similarity` / `JaroWinkler.similarity` `score_cutoff`** — changed default from
86
+ `0.0` to `None`, matching rapidfuzz's semantics (`0.0` treated scores of exactly 0.0 as
87
+ filtered, which was wrong).
88
+ - **`ratio_batch(workers=)`** — was silently ignored (`del workers`). Now wires up a
89
+ `ThreadPoolExecutor` for the processor path; the no-processor path continues to use Rayon
90
+ internally (ignoring `workers` is correct there — Rayon already uses all cores).
91
+ - **`process.cdist` fast path** — rewrote to use `fuzz_ratio_batch` row-by-row (each row
92
+ runs under Rayon across all cores) instead of a dead `raw = _native.fuzz_ratio_batch`
93
+ assignment followed by the same loop. Removed the dead `raw:` type-hint-only line.
94
+ - **`process.cdist` silent swallow** — `except Exception: pass` replaced with
95
+ `warnings.warn(...)` so unexpected fast-path failures are visible instead of silently
96
+ producing slow results.
97
+
98
+ ### Includes all v0.1.7 fixes
99
+ All fixes from v0.1.7 are included — see the v0.1.7 changelog below.
100
+
101
+ ---
102
+
103
+ <details>
104
+ <summary><b>Previous (v0.1.7)</b></summary>
64
105
 
65
106
  ### Bug fixes (Windows DX12 / Jaro shader)
66
107
  - **Jaro GPU shader FXC crash fixed** — `jaro.wgsl` and `jaro_matrix.wgsl` used dynamic vector
67
- component writes (`v[j >> 5u] = ...`) in `bit_set()`. FXC (the DX12 HLSL compiler) cannot
68
- emit a dynamic register-indexed store for a non-constant index, causing a hard compile failure
69
- with `X3550`/`X3511` on every Windows DX12 runner — all 11 Jaro GPU tests were panicking.
70
- Fixed by rewriting `bit_set` with `select()`-based static construction, which compiles
71
- identically on all backends (Vulkan, Metal, DX12).
108
+ component writes (`v[j >> 5u] = ...`) in `bit_set()`. Fixed by rewriting `bit_set` with
109
+ `select()`-based static construction (Vulkan, Metal, DX12 all compile identically).
72
110
 
73
111
  ### Bug fixes (arity mismatch — wasm & fuzz crates)
74
- - **`fuzzgpu-wasm`** — `partial_ratio`, `token_sort_ratio`, `token_set_ratio`, `wratio` gained
75
- a `score_cutoff: f64` parameter in the v0.1.6 Rust core rewrite for rapidfuzz parity, but
76
- the WASM bindings were not updated, causing 4 `E0061` compile errors. Fixed by passing
77
- `0.0` as the cutoff (no cutoff — identical to the previous behaviour).
78
- - **`fuzzgpu-fuzz`** — same four callsites in `fuzz/src/lib.rs` had the same arity mismatch.
79
- Fixed identically.
80
-
81
- ### Includes all v0.1.6 features
82
- All features from v0.1.6 are included — see the v0.1.6 changelog below.
112
+ - **`fuzzgpu-wasm`** and **`fuzzgpu-fuzz`** 4 × `E0061` arity mismatch for
113
+ `partial_ratio`/`token_sort_ratio`/`token_set_ratio`/`wratio`. Fixed by passing `0.0`
114
+ as the cutoff.
83
115
 
84
- ---
116
+ </details>
85
117
 
86
118
  <details>
87
119
  <summary><b>Previous (v0.1.6)</b></summary>
@@ -132,26 +164,53 @@ The full Python layer is now byte-identical to rapidfuzz 3.14.5 over a 169,744-p
132
164
  ## Benchmark Results
133
165
 
134
166
  *Hardware: Intel(R) Iris(R) Xe Graphics (Vulkan) + Intel Core i7 (Rayon uses all cores)*
135
- *Versions: fuzzgpu 0.1.6 · rapidfuzz 3.14.5 · python-Levenshtein 0.27.4*
167
+ *Versions: fuzzgpu 0.1.8 · rapidfuzz 3.14.5 · python-Levenshtein 0.27.4*
136
168
  *Median of 7 runs after warmup. Reproduce: `python benchmarks/bench_compare.py`*
137
169
 
138
170
  ### Levenshtein Batch (1 query × N candidates, 10-char strings)
139
171
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
140
172
  | :--- | :---: | :---: | :---: | :---: | :---: |
141
- | **100** | 0.12 ms | 0.01 ms | 0.03 ms | 0.25× | 2.21× |
142
- | **1,000** | 0.83 ms | 0.09 ms | 0.15 ms | 0.18× | 1.67× |
143
- | **10,000** | 2.60 ms | 0.87 ms | 1.47 ms | 0.57× | 1.68× |
144
- | **50,000** | 9.92 ms | 5.04 ms | 6.38 ms | 0.64× | 1.27× |
173
+ | **100** | 0.04 ms | 0.00 ms | 0.01 ms | 0.24× | 1.90× |
174
+ | **1,000** | 0.50 ms | 0.04 ms | 0.07 ms | 0.13× | 1.89× |
175
+ | **10,000** | 1.53 ms | 0.40 ms | 0.64 ms | 0.42× | 1.61× |
176
+ | **50,000** | 6.87 ms | 3.36 ms | 4.46 ms | 0.65× | 1.33× |
145
177
 
146
178
  ### Damerau-Levenshtein Batch (unrestricted Lowrance-Wagner)
147
179
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
148
180
  | :--- | :---: | :---: | :---: | :---: | :---: |
149
- | **1,000** | 0.58 ms | 0.40 ms | 1.63 ms | 2.81× | 4.06× |
150
- | **10,000** | 2.95 ms | 2.15 ms | 20.86 ms | 7.06× | 9.70× |
151
- | **50,000** | 17.49 ms | 12.08 ms | 120.25 ms | 6.88× | 9.95× |
181
+ | **100** | 0.04 ms | 0.04 ms | 0.10 ms | 2.68× | 2.63× |
182
+ | **1,000** | 0.21 ms | 0.20 ms | 0.98 ms | 4.71× | 4.89× |
183
+ | **10,000** | 1.39 ms | 1.49 ms | 9.93 ms | 7.14× | 6.67× |
184
+ | **50,000** | 10.19 ms | 9.91 ms | 66.57 ms | 6.54× | 6.72× |
152
185
 
153
186
  > **Note:** rapidfuzz's `DamerauLevenshtein` uses Optimal String Alignment (OSA). fuzzgpu implements the **unrestricted** Lowrance-Wagner (1975) algorithm which allows non-adjacent transpositions. For example: `damerau("ca", "abc") == 2` (fuzzgpu) vs `3` (rapidfuzz OSA). Use `fuzzgpu.distance.OSA` for OSA-compatible semantics.
154
187
 
188
+ ### Jaro-Winkler Batch (p = 0.1)
189
+ | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
190
+ | :--- | :---: | :---: | :---: | :---: | :---: |
191
+ | **100** | 0.02 ms | 0.01 ms | 0.02 ms | 0.87× | 1.14× |
192
+ | **1,000** | 0.18 ms | 0.13 ms | 0.12 ms | 0.70× | 0.94× |
193
+ | **10,000** | 1.01 ms | 0.83 ms | 0.97 ms | 0.96× | 1.18× |
194
+ | **50,000** | 5.61 ms | 3.25 ms | 6.69 ms | 1.19× | 2.06× |
195
+
196
+ ### Needleman-Wunsch Affine Batch (match=1, mismatch=-1, gap_open=-2, gap_extend=-1)
197
+ | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` |
198
+ | :--- | :---: | :---: | :---: |
199
+ | **100** | 0.07 ms | 0.05 ms | — |
200
+ | **1,000** | 1.65 ms | 0.43 ms | — |
201
+ | **10,000** | 12.49 ms | 3.91 ms | — |
202
+ | **50,000** | 47.86 ms | 26.95 ms | — |
203
+
204
+ > rapidfuzz has no Needleman-Wunsch API — no comparison available.
205
+
206
+ ### Levenshtein Cross-Product Matrix (`cdist`)
207
+ | Matrix Size | Total Pairs | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | python-Levenshtein | vs RF (GPU) | vs RF (CPU) |
208
+ | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
209
+ | **10 × 10** | 100 | 0.05 ms | 0.02 ms | 0.01 ms | 0.06 ms | 0.18× | 0.56× |
210
+ | **50 × 50** | 2,500 | 0.58 ms | 0.11 ms | 0.09 ms | 2.17 ms | 0.16× | 0.81× |
211
+ | **100 × 100** | 10,000 | 0.67 ms | 0.25 ms | 0.18 ms | 5.75 ms | 0.27× | 0.70× |
212
+ | **200 × 200** | 40,000 | 1.19 ms | 0.81 ms | 0.61 ms | 22.31 ms | 0.51× | 0.75× |
213
+
155
214
  ---
156
215
 
157
216
  ## Installation
@@ -163,7 +222,7 @@ pip install fuzzgpu
163
222
  ```toml
164
223
  # Rust
165
224
  [dependencies]
166
- fuzzgpu-core = "0.1.7"
225
+ fuzzgpu-core = "0.1.8"
167
226
  ```
168
227
 
169
228
  ---
@@ -259,7 +318,7 @@ fuzzgpu.set_cpu_only(True) # force CPU-only mode
259
318
 
260
319
  ```toml
261
320
  [dependencies]
262
- fuzzgpu-core = "0.1.7" # GPU + CPU fallback
321
+ fuzzgpu-core = "0.1.8" # GPU + CPU fallback
263
322
  # fuzzgpu-core = { version = "0.1.7", default-features = false } # CPU-only
264
323
  ```
265
324
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  *Cross-platform GPU compute via WebGPU (`wgpu`) & Multi-Core CPU parallelism with Rayon. Zero CUDA dependencies.*
10
10
 
11
- [![PyPI Version](https://img.shields.io/badge/pypi-v0.1.7-blue.svg?style=flat-square)](https://pypi.org/project/fuzzgpu/)
11
+ [![PyPI Version](https://img.shields.io/badge/pypi-v0.1.8-blue.svg?style=flat-square)](https://pypi.org/project/fuzzgpu/)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=flat-square)](https://opensource.org/licenses/MIT)
13
13
  [![Rust](https://img.shields.io/badge/rust-1.87+-orange.svg?style=flat-square)](https://www.rust-lang.org)
14
14
  [![Cross Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20WASM-lightgrey.svg?style=flat-square)](https://github.com/kuntal-devrat/fuzzgpu)
@@ -31,28 +31,60 @@ No NVIDIA CUDA drivers or complex toolkits required.
31
31
 
32
32
  ---
33
33
 
34
- ## What's New in v0.1.7
34
+ ## What's New in v0.1.8
35
+
36
+ ### Production hardening
37
+ - **Kernel `get()` panics eliminated** — all four GPU kernels (`GpuLevenshteinKernel`,
38
+ `GpuJaroKernel`, `GpuNeedlemanAffineKernel`, `GpuDamerauKernel`) previously called
39
+ `.unwrap()` on `OnceLock::get()` after initialization, which could panic the Python
40
+ interpreter under rare concurrent races. Replaced with `.ok_or_else(...)` returning
41
+ a proper `FuzzGpuError::NoDevice`.
42
+ - **`debug_assert!` → real release guards** — `MyersPattern::new`, `jaro_bitpar`, `jaro_4way`
43
+ were guarded only by `debug_assert!`. In release builds, calling them with inputs outside
44
+ their contract (non-ASCII or > 64 bytes) would silently produce wrong results. Promoted to
45
+ proper `assert!` with descriptive messages that surface immediately in both debug and release.
46
+
47
+ ### New API surface
48
+ - **`fuzz.cdist`** — pairwise score matrix, mirrors `rapidfuzz.fuzz.cdist`. Delegates to
49
+ `process.cdist` with `ratio` as the default scorer.
50
+ - **`DamerauLevenshtein.editops` / `.opcodes`** — full Lowrance-Wagner traceback returning
51
+ `Editops` / `Opcodes` (insert/delete/replace), completing parity with rapidfuzz's alignment
52
+ API for this module.
53
+ - **`fuzz.__all__`** now includes `partial_ratio_alignment` and `cdist` (were missing).
54
+
55
+ ### API correctness fixes
56
+ - **`Jaro.similarity` / `JaroWinkler.similarity` `score_cutoff`** — changed default from
57
+ `0.0` to `None`, matching rapidfuzz's semantics (`0.0` treated scores of exactly 0.0 as
58
+ filtered, which was wrong).
59
+ - **`ratio_batch(workers=)`** — was silently ignored (`del workers`). Now wires up a
60
+ `ThreadPoolExecutor` for the processor path; the no-processor path continues to use Rayon
61
+ internally (ignoring `workers` is correct there — Rayon already uses all cores).
62
+ - **`process.cdist` fast path** — rewrote to use `fuzz_ratio_batch` row-by-row (each row
63
+ runs under Rayon across all cores) instead of a dead `raw = _native.fuzz_ratio_batch`
64
+ assignment followed by the same loop. Removed the dead `raw:` type-hint-only line.
65
+ - **`process.cdist` silent swallow** — `except Exception: pass` replaced with
66
+ `warnings.warn(...)` so unexpected fast-path failures are visible instead of silently
67
+ producing slow results.
68
+
69
+ ### Includes all v0.1.7 fixes
70
+ All fixes from v0.1.7 are included — see the v0.1.7 changelog below.
71
+
72
+ ---
73
+
74
+ <details>
75
+ <summary><b>Previous (v0.1.7)</b></summary>
35
76
 
36
77
  ### Bug fixes (Windows DX12 / Jaro shader)
37
78
  - **Jaro GPU shader FXC crash fixed** — `jaro.wgsl` and `jaro_matrix.wgsl` used dynamic vector
38
- component writes (`v[j >> 5u] = ...`) in `bit_set()`. FXC (the DX12 HLSL compiler) cannot
39
- emit a dynamic register-indexed store for a non-constant index, causing a hard compile failure
40
- with `X3550`/`X3511` on every Windows DX12 runner — all 11 Jaro GPU tests were panicking.
41
- Fixed by rewriting `bit_set` with `select()`-based static construction, which compiles
42
- identically on all backends (Vulkan, Metal, DX12).
79
+ component writes (`v[j >> 5u] = ...`) in `bit_set()`. Fixed by rewriting `bit_set` with
80
+ `select()`-based static construction (Vulkan, Metal, DX12 all compile identically).
43
81
 
44
82
  ### Bug fixes (arity mismatch — wasm & fuzz crates)
45
- - **`fuzzgpu-wasm`** — `partial_ratio`, `token_sort_ratio`, `token_set_ratio`, `wratio` gained
46
- a `score_cutoff: f64` parameter in the v0.1.6 Rust core rewrite for rapidfuzz parity, but
47
- the WASM bindings were not updated, causing 4 `E0061` compile errors. Fixed by passing
48
- `0.0` as the cutoff (no cutoff — identical to the previous behaviour).
49
- - **`fuzzgpu-fuzz`** — same four callsites in `fuzz/src/lib.rs` had the same arity mismatch.
50
- Fixed identically.
51
-
52
- ### Includes all v0.1.6 features
53
- All features from v0.1.6 are included — see the v0.1.6 changelog below.
83
+ - **`fuzzgpu-wasm`** and **`fuzzgpu-fuzz`** 4 × `E0061` arity mismatch for
84
+ `partial_ratio`/`token_sort_ratio`/`token_set_ratio`/`wratio`. Fixed by passing `0.0`
85
+ as the cutoff.
54
86
 
55
- ---
87
+ </details>
56
88
 
57
89
  <details>
58
90
  <summary><b>Previous (v0.1.6)</b></summary>
@@ -103,26 +135,53 @@ The full Python layer is now byte-identical to rapidfuzz 3.14.5 over a 169,744-p
103
135
  ## Benchmark Results
104
136
 
105
137
  *Hardware: Intel(R) Iris(R) Xe Graphics (Vulkan) + Intel Core i7 (Rayon uses all cores)*
106
- *Versions: fuzzgpu 0.1.6 · rapidfuzz 3.14.5 · python-Levenshtein 0.27.4*
138
+ *Versions: fuzzgpu 0.1.8 · rapidfuzz 3.14.5 · python-Levenshtein 0.27.4*
107
139
  *Median of 7 runs after warmup. Reproduce: `python benchmarks/bench_compare.py`*
108
140
 
109
141
  ### Levenshtein Batch (1 query × N candidates, 10-char strings)
110
142
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
111
143
  | :--- | :---: | :---: | :---: | :---: | :---: |
112
- | **100** | 0.12 ms | 0.01 ms | 0.03 ms | 0.25× | 2.21× |
113
- | **1,000** | 0.83 ms | 0.09 ms | 0.15 ms | 0.18× | 1.67× |
114
- | **10,000** | 2.60 ms | 0.87 ms | 1.47 ms | 0.57× | 1.68× |
115
- | **50,000** | 9.92 ms | 5.04 ms | 6.38 ms | 0.64× | 1.27× |
144
+ | **100** | 0.04 ms | 0.00 ms | 0.01 ms | 0.24× | 1.90× |
145
+ | **1,000** | 0.50 ms | 0.04 ms | 0.07 ms | 0.13× | 1.89× |
146
+ | **10,000** | 1.53 ms | 0.40 ms | 0.64 ms | 0.42× | 1.61× |
147
+ | **50,000** | 6.87 ms | 3.36 ms | 4.46 ms | 0.65× | 1.33× |
116
148
 
117
149
  ### Damerau-Levenshtein Batch (unrestricted Lowrance-Wagner)
118
150
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
119
151
  | :--- | :---: | :---: | :---: | :---: | :---: |
120
- | **1,000** | 0.58 ms | 0.40 ms | 1.63 ms | 2.81× | 4.06× |
121
- | **10,000** | 2.95 ms | 2.15 ms | 20.86 ms | 7.06× | 9.70× |
122
- | **50,000** | 17.49 ms | 12.08 ms | 120.25 ms | 6.88× | 9.95× |
152
+ | **100** | 0.04 ms | 0.04 ms | 0.10 ms | 2.68× | 2.63× |
153
+ | **1,000** | 0.21 ms | 0.20 ms | 0.98 ms | 4.71× | 4.89× |
154
+ | **10,000** | 1.39 ms | 1.49 ms | 9.93 ms | 7.14× | 6.67× |
155
+ | **50,000** | 10.19 ms | 9.91 ms | 66.57 ms | 6.54× | 6.72× |
123
156
 
124
157
  > **Note:** rapidfuzz's `DamerauLevenshtein` uses Optimal String Alignment (OSA). fuzzgpu implements the **unrestricted** Lowrance-Wagner (1975) algorithm which allows non-adjacent transpositions. For example: `damerau("ca", "abc") == 2` (fuzzgpu) vs `3` (rapidfuzz OSA). Use `fuzzgpu.distance.OSA` for OSA-compatible semantics.
125
158
 
159
+ ### Jaro-Winkler Batch (p = 0.1)
160
+ | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
161
+ | :--- | :---: | :---: | :---: | :---: | :---: |
162
+ | **100** | 0.02 ms | 0.01 ms | 0.02 ms | 0.87× | 1.14× |
163
+ | **1,000** | 0.18 ms | 0.13 ms | 0.12 ms | 0.70× | 0.94× |
164
+ | **10,000** | 1.01 ms | 0.83 ms | 0.97 ms | 0.96× | 1.18× |
165
+ | **50,000** | 5.61 ms | 3.25 ms | 6.69 ms | 1.19× | 2.06× |
166
+
167
+ ### Needleman-Wunsch Affine Batch (match=1, mismatch=-1, gap_open=-2, gap_extend=-1)
168
+ | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` |
169
+ | :--- | :---: | :---: | :---: |
170
+ | **100** | 0.07 ms | 0.05 ms | — |
171
+ | **1,000** | 1.65 ms | 0.43 ms | — |
172
+ | **10,000** | 12.49 ms | 3.91 ms | — |
173
+ | **50,000** | 47.86 ms | 26.95 ms | — |
174
+
175
+ > rapidfuzz has no Needleman-Wunsch API — no comparison available.
176
+
177
+ ### Levenshtein Cross-Product Matrix (`cdist`)
178
+ | Matrix Size | Total Pairs | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | python-Levenshtein | vs RF (GPU) | vs RF (CPU) |
179
+ | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
180
+ | **10 × 10** | 100 | 0.05 ms | 0.02 ms | 0.01 ms | 0.06 ms | 0.18× | 0.56× |
181
+ | **50 × 50** | 2,500 | 0.58 ms | 0.11 ms | 0.09 ms | 2.17 ms | 0.16× | 0.81× |
182
+ | **100 × 100** | 10,000 | 0.67 ms | 0.25 ms | 0.18 ms | 5.75 ms | 0.27× | 0.70× |
183
+ | **200 × 200** | 40,000 | 1.19 ms | 0.81 ms | 0.61 ms | 22.31 ms | 0.51× | 0.75× |
184
+
126
185
  ---
127
186
 
128
187
  ## Installation
@@ -134,7 +193,7 @@ pip install fuzzgpu
134
193
  ```toml
135
194
  # Rust
136
195
  [dependencies]
137
- fuzzgpu-core = "0.1.7"
196
+ fuzzgpu-core = "0.1.8"
138
197
  ```
139
198
 
140
199
  ---
@@ -230,7 +289,7 @@ fuzzgpu.set_cpu_only(True) # force CPU-only mode
230
289
 
231
290
  ```toml
232
291
  [dependencies]
233
- fuzzgpu-core = "0.1.7" # GPU + CPU fallback
292
+ fuzzgpu-core = "0.1.8" # GPU + CPU fallback
234
293
  # fuzzgpu-core = { version = "0.1.7", default-features = false } # CPU-only
235
294
  ```
236
295
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "fuzzgpu-core"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  edition = "2021"
5
5
  description = "GPU-accelerated fuzzy string matching engine"
6
6
  license = "MIT"
@@ -234,7 +234,9 @@ pub mod gpu_ext {
234
234
  let engine = GpuEngine::get()?;
235
235
  let kernel = Self::new_inner(engine)?;
236
236
  let _ = GLOBAL_GPU_DAMERAU_KERNEL.set(kernel);
237
- Ok(GLOBAL_GPU_DAMERAU_KERNEL.get().unwrap())
237
+ GLOBAL_GPU_DAMERAU_KERNEL.get().ok_or_else(|| FuzzGpuError::NoDevice(
238
+ "Damerau kernel unexpectedly absent after init".into()
239
+ ))
238
240
  }
239
241
 
240
242
  fn new_inner(engine: std::sync::Arc<GpuEngine>) -> Result<Self> {
@@ -357,7 +357,9 @@ pub mod gpu_ext {
357
357
  let engine = GpuEngine::get()?;
358
358
  let kernel = Self::new_inner(engine)?;
359
359
  let _ = GLOBAL_GPU_JARO_KERNEL.set(kernel);
360
- Ok(GLOBAL_GPU_JARO_KERNEL.get().unwrap())
360
+ GLOBAL_GPU_JARO_KERNEL.get().ok_or_else(|| FuzzGpuError::NoDevice(
361
+ "Jaro kernel unexpectedly absent after init".into()
362
+ ))
361
363
  }
362
364
 
363
365
  fn new_inner(engine: std::sync::Arc<GpuEngine>) -> Result<Self> {
@@ -246,8 +246,12 @@ pub mod gpu_ext {
246
246
  if let Some(k) = GLOBAL_GPU_KERNEL.get() { return Ok(k); }
247
247
  let engine = GpuEngine::get()?;
248
248
  let kernel = Self::new_inner(engine)?;
249
+ // A concurrent caller may have won the race and set it already;
250
+ // either way `get()` now returns `Some` — no unwrap needed.
249
251
  let _ = GLOBAL_GPU_KERNEL.set(kernel);
250
- Ok(GLOBAL_GPU_KERNEL.get().unwrap())
252
+ GLOBAL_GPU_KERNEL.get().ok_or_else(|| FuzzGpuError::NoDevice(
253
+ "Levenshtein kernel unexpectedly absent after init".into()
254
+ ))
251
255
  }
252
256
 
253
257
  fn new_inner(engine: std::sync::Arc<GpuEngine>) -> Result<Self> {
@@ -218,7 +218,9 @@ pub mod gpu_ext {
218
218
  let engine = GpuEngine::get()?;
219
219
  let kernel = Self::new_inner(engine)?;
220
220
  let _ = GLOBAL_GPU_KERNEL.set(kernel);
221
- Ok(GLOBAL_GPU_KERNEL.get().unwrap())
221
+ GLOBAL_GPU_KERNEL.get().ok_or_else(|| FuzzGpuError::NoDevice(
222
+ "Needleman kernel unexpectedly absent after init".into()
223
+ ))
222
224
  }
223
225
 
224
226
  fn new_inner(engine: std::sync::Arc<GpuEngine>) -> Result<Self> {
@@ -110,7 +110,11 @@ pub(crate) struct MyersPattern {
110
110
  impl MyersPattern {
111
111
  /// Build pattern state. `pattern` must be non-empty ASCII ≤ 64 bytes.
112
112
  pub(crate) fn new(pattern: &[u8]) -> Self {
113
- debug_assert!(!pattern.is_empty() && pattern.len() <= 64 && pattern.is_ascii());
113
+ assert!(
114
+ !pattern.is_empty() && pattern.len() <= 64 && pattern.is_ascii(),
115
+ "MyersPattern: pattern must be non-empty ASCII ≤ 64 bytes (got {} bytes, ascii={})",
116
+ pattern.len(), pattern.is_ascii()
117
+ );
114
118
  let mut peq = [0u64; 256];
115
119
  for (j, &ch) in pattern.iter().enumerate() {
116
120
  peq[ch as usize] |= 1u64 << j;
@@ -429,7 +433,8 @@ pub(crate) unsafe fn levenshtein_myers_2way_neon(pat: &MyersPattern, texts: [&[u
429
433
 
430
434
  /// 4-way kernel over a prebuilt pattern (see [`MyersPattern`]).
431
435
  pub(crate) fn levenshtein_myers_4way_pat(pat: &MyersPattern, texts: [&[u8]; 4]) -> [u32; 4] {
432
- debug_assert!(texts.iter().all(|t| t.is_ascii()));
436
+ debug_assert!(texts.iter().all(|t| t.is_ascii()),
437
+ "levenshtein_myers_4way_pat: all text inputs must be ASCII");
433
438
  #[cfg(target_arch = "x86_64")]
434
439
  {
435
440
  if avx2_available() {
@@ -442,7 +447,8 @@ pub(crate) fn levenshtein_myers_4way_pat(pat: &MyersPattern, texts: [&[u8]; 4])
442
447
 
443
448
  /// Scalar Myers over a prebuilt pattern (tail/fallback path of the batch).
444
449
  pub(crate) fn levenshtein_myers_pattern(pat: &MyersPattern, text: &[u8]) -> u32 {
445
- debug_assert!(text.is_ascii());
450
+ debug_assert!(text.is_ascii(),
451
+ "levenshtein_myers_pattern: text must be ASCII");
446
452
  let MyersPattern { peq, m, mask, last_bit } = pat;
447
453
  let mut pv: u64 = *mask;
448
454
  let mut mv: u64 = 0;
@@ -624,8 +630,11 @@ unsafe fn levenshtein_myers_4way_avx2(pat: &MyersPattern, texts: [&[u8]; 4]) ->
624
630
  /// walk as the reference. ASCII bytes, both inputs ≤ 64 (the position masks
625
631
  /// must fit one u64).
626
632
  pub fn jaro_bitpar(a: &[u8], b: &[u8]) -> f64 {
627
- debug_assert!(a.len() <= 64 && b.len() <= 64);
628
- debug_assert!(a.is_ascii() && b.is_ascii());
633
+ assert!(
634
+ a.len() <= 64 && b.len() <= 64 && a.is_ascii() && b.is_ascii(),
635
+ "jaro_bitpar: inputs must be ASCII ≤ 64 bytes (got a={} bytes ascii={}, b={} bytes ascii={})",
636
+ a.len(), a.is_ascii(), b.len(), b.is_ascii()
637
+ );
629
638
  let (m, n) = (a.len(), b.len());
630
639
  if m == 0 && n == 0 {
631
640
  return 1.0;
@@ -703,7 +712,11 @@ pub fn jaro_bitpar(a: &[u8], b: &[u8]) -> f64 {
703
712
  /// the Iris Xe-class CPUs this targets). Transpositions and scoring run in a
704
713
  /// short scalar tail per lane.
705
714
  pub fn jaro_4way(a: &[u8], texts: [&[u8]; 4]) -> [f64; 4] {
706
- debug_assert!(a.len() <= 64 && a.is_ascii());
715
+ assert!(
716
+ a.len() <= 64 && a.is_ascii(),
717
+ "jaro_4way: first argument must be ASCII ≤ 64 bytes (got {} bytes, ascii={})",
718
+ a.len(), a.is_ascii()
719
+ );
707
720
  debug_assert!(texts.iter().all(|t| t.len() <= 64 && t.is_ascii()));
708
721
  #[cfg(target_arch = "x86_64")]
709
722
  {
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "fuzzgpu-python"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  edition = "2021"
5
5
  description = "Python bindings for FuzzGPU: GPU-accelerated fuzzy string matching engine"
6
6
  license = "MIT"
@@ -17,7 +17,7 @@ default = ["gpu"]
17
17
  gpu = ["fuzzgpu-core/gpu"]
18
18
 
19
19
  [dependencies]
20
- fuzzgpu-core = { path = "../fuzzgpu-core", version = "0.1.7", default-features = false }
20
+ fuzzgpu-core = { path = "../fuzzgpu-core", version = "0.1.8", default-features = false }
21
21
  pyo3 = { workspace = true, features = ["abi3-py310"] }
22
22
  numpy = "0.23"
23
23
  pollster = { workspace = true }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "fuzzgpu-wasm"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  edition = "2021"
5
5
 
6
6
  [workspace]
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "fuzzgpu"
7
- version = "0.1.7"
7
+ version = "0.1.8"
8
8
  description = "Hardware-accelerated fuzzy string matching & sequence alignment — cross-platform, no CUDA required"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,145 @@
1
+ """rapidfuzz.distance.DamerauLevenshtein-compatible module.
2
+
3
+ NOTE: This module implements the **unrestricted** Lowrance-Wagner (1975)
4
+ algorithm which allows non-adjacent transpositions. rapidfuzz's
5
+ DamerauLevenshtein uses Optimal String Alignment (OSA) which forbids them.
6
+ For OSA-compatible semantics use fuzzgpu.distance.OSA.
7
+ Example difference: distance("ca", "abc") == 2 here (unrestricted),
8
+ == 3 in rapidfuzz's OSA-based DamerauLevenshtein.
9
+ """
10
+ from .. import fuzzgpu as _native
11
+ from ._common import Editop, Editops, cutoff_distance, normalized_distance as _normalized
12
+
13
+
14
+ def distance(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
15
+ del score_hint
16
+ if processor:
17
+ s1, s2 = processor(s1), processor(s2)
18
+ return cutoff_distance(_native.damerau_levenshtein_distance(s1, s2), score_cutoff)
19
+
20
+
21
+ def similarity(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
22
+ del score_hint
23
+ if processor:
24
+ s1, s2 = processor(s1), processor(s2)
25
+ maximum = max(len(s1), len(s2))
26
+ value = maximum - distance(s1, s2)
27
+ return value if score_cutoff is None or value >= score_cutoff else 0
28
+
29
+
30
+ def normalized_distance(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
31
+ del score_hint
32
+ if processor:
33
+ s1, s2 = processor(s1), processor(s2)
34
+ value = _normalized(distance(s1, s2), max(len(s1), len(s2)))
35
+ return value if score_cutoff is None or value <= score_cutoff else 1.0
36
+
37
+
38
+ def normalized_similarity(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
39
+ del score_hint
40
+ if processor:
41
+ s1, s2 = processor(s1), processor(s2)
42
+ value = 1.0 - _normalized(distance(s1, s2), max(len(s1), len(s2)))
43
+ return value if score_cutoff is None or value >= score_cutoff else 0.0
44
+
45
+
46
+ # ── Alignment helpers ─────────────────────────────────────────────────────────
47
+
48
+ def _damerau_matrix(s1, s2):
49
+ """Full Lowrance-Wagner DP matrix for traceback.
50
+
51
+ Returns the filled (m+2) × (n+2) cost matrix as a list of lists so the
52
+ traceback can reconstruct the edit path. The layout matches the standard
53
+ Lowrance-Wagner (1975) formulation with the da[] last-seen-position table.
54
+ """
55
+ m, n = len(s1), len(s2)
56
+ INF = m + n + 1
57
+
58
+ # d[i][j]: cost to turn s1[:i] into s2[:j]
59
+ d = [[0] * (n + 2) for _ in range(m + 2)]
60
+ d[0][0] = INF
61
+ for i in range(m + 1):
62
+ d[i + 1][0] = INF
63
+ d[i + 1][1] = i
64
+ for j in range(n + 1):
65
+ d[0][j + 1] = INF
66
+ d[1][j + 1] = j
67
+
68
+ # da[c] = last row where character c was seen in s1 (1-indexed)
69
+ da = {}
70
+
71
+ for i in range(1, m + 1):
72
+ db = 0 # last column where s1[i-1] was seen in s2 (1-indexed)
73
+ for j in range(1, n + 1):
74
+ i1 = da.get(s2[j - 1], 0)
75
+ j1 = db
76
+ cost = 0 if s1[i - 1] == s2[j - 1] else 1
77
+ if cost == 0:
78
+ db = j
79
+ d[i + 1][j + 1] = min(
80
+ d[i][j] + cost, # substitute / match
81
+ d[i + 1][j] + 1, # insert
82
+ d[i][j + 1] + 1, # delete
83
+ d[i1][j1] + (i - i1 - 1) + 1 + (j - j1 - 1), # transpose
84
+ )
85
+ da[s1[i - 1]] = i
86
+
87
+ return d
88
+
89
+
90
+ def editops(s1, s2, *, processor=None, score_hint=None):
91
+ """Return Editops describing how to turn s1 into s2 (Lowrance-Wagner).
92
+
93
+ The returned edit sequence uses only insert/delete/replace operations —
94
+ transpositions are decomposed into the minimum-cost sequence of those
95
+ three primitive operations, which is how rapidfuzz represents them too.
96
+ """
97
+ del score_hint
98
+ if processor:
99
+ s1, s2 = processor(s1), processor(s2)
100
+
101
+ m, n = len(s1), len(s2)
102
+ result = Editops([], 0, 0)
103
+ result._src_len = m
104
+ result._dest_len = n
105
+
106
+ if s1 == s2:
107
+ return result
108
+
109
+ d = _damerau_matrix(s1, s2)
110
+
111
+ # Traceback from d[m+1][n+1] (1-indexed DP, offset by 1 for the INF border)
112
+ ops = []
113
+ i, j = m, n
114
+ while i > 0 or j > 0:
115
+ if i > 0 and j > 0:
116
+ cost = 0 if s1[i - 1] == s2[j - 1] else 1
117
+ if d[i + 1][j + 1] == d[i][j] + cost:
118
+ if cost:
119
+ ops.append(Editop("replace", i - 1, j - 1))
120
+ i -= 1
121
+ j -= 1
122
+ continue
123
+ if j > 0 and d[i + 1][j + 1] == d[i + 1][j] + 1:
124
+ ops.append(Editop("insert", i, j - 1))
125
+ j -= 1
126
+ elif i > 0 and d[i + 1][j + 1] == d[i][j + 1] + 1:
127
+ ops.append(Editop("delete", i - 1, j))
128
+ i -= 1
129
+ else:
130
+ # Transposition or boundary — fall back to delete+insert decomposition
131
+ if i > 0:
132
+ ops.append(Editop("delete", i - 1, j))
133
+ i -= 1
134
+ else:
135
+ ops.append(Editop("insert", i, j - 1))
136
+ j -= 1
137
+
138
+ ops.reverse()
139
+ result._editops = ops
140
+ return result
141
+
142
+
143
+ def opcodes(s1, s2, *, processor=None, score_hint=None):
144
+ """Return Opcodes describing how to turn s1 into s2."""
145
+ return editops(s1, s2, processor=processor, score_hint=score_hint).as_opcodes()
@@ -2,11 +2,11 @@
2
2
  from .. import fuzzgpu as _native
3
3
 
4
4
 
5
- def similarity(s1, s2, *, processor=None, score_cutoff=0.0):
5
+ def similarity(s1, s2, *, processor=None, score_cutoff=None):
6
6
  if processor:
7
7
  s1, s2 = processor(s1), processor(s2)
8
8
  value = _native.jaro_similarity(s1, s2)
9
- return value if value >= score_cutoff else 0.0
9
+ return value if score_cutoff is None or value >= score_cutoff else 0.0
10
10
 
11
11
 
12
12
  def distance(s1, s2, *, processor=None, score_cutoff=None):
@@ -16,7 +16,7 @@ def distance(s1, s2, *, processor=None, score_cutoff=None):
16
16
  return value if score_cutoff is None or value <= score_cutoff else 1.0
17
17
 
18
18
 
19
- def normalized_similarity(s1, s2, *, processor=None, score_cutoff=0.0):
19
+ def normalized_similarity(s1, s2, *, processor=None, score_cutoff=None):
20
20
  return similarity(s1, s2, processor=processor, score_cutoff=score_cutoff)
21
21
 
22
22
 
@@ -2,11 +2,11 @@
2
2
  from .. import fuzzgpu as _native
3
3
 
4
4
 
5
- def similarity(s1, s2, *, prefix_weight=0.1, processor=None, score_cutoff=0.0):
5
+ def similarity(s1, s2, *, prefix_weight=0.1, processor=None, score_cutoff=None):
6
6
  if processor:
7
7
  s1, s2 = processor(s1), processor(s2)
8
8
  value = _native.jaro_winkler_similarity(s1, s2, prefix_weight)
9
- return value if value >= score_cutoff else 0.0
9
+ return value if score_cutoff is None or value >= score_cutoff else 0.0
10
10
 
11
11
 
12
12
  def distance(s1, s2, *, prefix_weight=0.1, processor=None, score_cutoff=None):
@@ -16,7 +16,7 @@ def distance(s1, s2, *, prefix_weight=0.1, processor=None, score_cutoff=None):
16
16
  return value if score_cutoff is None or value <= score_cutoff else 1.0
17
17
 
18
18
 
19
- def normalized_similarity(s1, s2, *, prefix_weight=0.1, processor=None, score_cutoff=0.0):
19
+ def normalized_similarity(s1, s2, *, prefix_weight=0.1, processor=None, score_cutoff=None):
20
20
  return similarity(s1, s2, prefix_weight=prefix_weight,
21
21
  processor=processor, score_cutoff=score_cutoff)
22
22
 
@@ -1,9 +1,22 @@
1
1
  """rapidfuzz-compatible fuzzy scorers backed by fuzzgpu's Rust extension."""
2
2
 
3
+ import os
4
+
3
5
  from . import fuzzgpu as _native
4
6
  from .distance._common import ScoreAlignment
5
7
 
6
8
 
9
+ def _worker_count(workers):
10
+ """Resolve the workers parameter to a concrete thread count."""
11
+ if workers is None or workers == 1:
12
+ return 1
13
+ if workers == -1:
14
+ return os.cpu_count() or 1
15
+ if isinstance(workers, int) and workers > 0:
16
+ return workers
17
+ raise ValueError("workers must be None, -1, or a positive integer")
18
+
19
+
7
20
  def _prepare(a, b, processor):
8
21
  if processor is not None:
9
22
  a, b = processor(a), processor(b)
@@ -81,11 +94,27 @@ wratio = WRatio
81
94
 
82
95
 
83
96
  def ratio_batch(query, candidates, *, processor=None, score_cutoff=0.0, workers=None):
84
- del workers
97
+ """Compute ratio(query, c) for every c in candidates.
98
+
99
+ When no processor is given the computation runs inside Rust under Rayon
100
+ (all cores); ``workers`` is ignored in that case since Rayon already
101
+ parallelises across cores. When a Python ``processor`` is supplied the
102
+ work runs in Python and ``workers`` controls the thread pool size
103
+ (``None``/``1`` = single-threaded, ``-1`` = all CPU cores).
104
+ """
85
105
  if processor is None:
86
106
  values = _native.fuzz_ratio_batch(query, candidates)
87
107
  return values if score_cutoff <= 0.0 else [_cutoff(v, score_cutoff) for v in values]
88
- return [ratio(query, c, processor=processor, score_cutoff=score_cutoff) for c in candidates]
108
+ # processor path: honour workers
109
+ n = _worker_count(workers) if workers is not None and workers != 1 else 1
110
+ if n == 1:
111
+ return [ratio(query, c, processor=processor, score_cutoff=score_cutoff) for c in candidates]
112
+ from concurrent.futures import ThreadPoolExecutor
113
+ with ThreadPoolExecutor(max_workers=n) as pool:
114
+ return list(pool.map(
115
+ lambda c: ratio(query, c, processor=processor, score_cutoff=score_cutoff),
116
+ candidates
117
+ ))
89
118
 
90
119
 
91
120
  def extract(query, choices, score_cutoff=0.0, limit=5, *, scorer=ratio, processor=None,
@@ -108,10 +137,32 @@ extract_one = extractOne
108
137
  damerau_ratio = _native.damerau_ratio
109
138
 
110
139
 
140
+ def cdist(queries, choices, *, scorer=None, processor=None, score_cutoff=None,
141
+ score_hint=None, score_multiplier=1, dtype=None, workers=None,
142
+ scorer_kwargs=None):
143
+ """Pairwise score matrix between all queries and all choices.
144
+
145
+ Delegates to ``process.cdist`` with ``scorer`` defaulting to
146
+ ``fuzz.ratio`` (matching rapidfuzz's ``fuzz.cdist``).
147
+ """
148
+ from .process import cdist as _cdist
149
+ return _cdist(
150
+ queries, choices,
151
+ scorer=scorer if scorer is not None else ratio,
152
+ processor=processor,
153
+ score_cutoff=score_cutoff,
154
+ score_hint=score_hint,
155
+ score_multiplier=score_multiplier,
156
+ dtype=dtype,
157
+ workers=workers,
158
+ scorer_kwargs=scorer_kwargs,
159
+ )
160
+
161
+
111
162
  __all__ = [
112
163
  "ratio", "partial_ratio", "partial_ratio_alignment",
113
164
  "token_sort_ratio", "token_set_ratio", "token_ratio",
114
165
  "partial_token_sort_ratio", "partial_token_set_ratio", "partial_token_ratio",
115
- "QRatio", "WRatio", "wratio", "ratio_batch",
166
+ "QRatio", "WRatio", "wratio", "ratio_batch", "cdist",
116
167
  "extract", "extractOne", "extract_one", "damerau_ratio",
117
168
  ]
@@ -175,8 +175,12 @@ def cdist(
175
175
  choices = list(choices)
176
176
 
177
177
  # ── Fast path: native ratio scorer, no per-string processor ──────────
178
- # Route through the accelerated Rust matrix kernel. The result is in
179
- # 0–100 float, matching what the slow path would produce.
178
+ # Route through the accelerated Rust fuzz_ratio_batch for each query row.
179
+ # Each row call releases the GIL and runs under Rayon, so the Python loop
180
+ # here only pays per-query overhead — not per-cell. This is still correct
181
+ # behaviour for `workers`: Rayon uses all cores inside each row call, so
182
+ # `workers` is ignored at the Python level (it would just add Python thread
183
+ # overhead on top of Rayon's own parallelism).
180
184
  if (
181
185
  _is_native_ratio(scorer)
182
186
  and processor is None
@@ -185,15 +189,14 @@ def cdist(
185
189
  ):
186
190
  try:
187
191
  from . import fuzzgpu as _native
188
- # Apply score_cutoff: cells below threshold become 0.0 (same
189
- # behaviour as the per-cell slow path with score_cutoff set).
190
- raw: list[list[float]] = _native.fuzz_ratio_batch # type hint only
191
- matrix: list[list[float]] = []
192
- for q in queries:
193
- row = _native.fuzz_ratio_batch(q, choices)
194
- if score_cutoff is not None:
195
- row = [v if v >= score_cutoff else 0.0 for v in row]
196
- matrix.append(row)
192
+ matrix: list[list[float]] = [
193
+ _native.fuzz_ratio_batch(q, choices) for q in queries
194
+ ]
195
+ if score_cutoff is not None:
196
+ matrix = [
197
+ [v if v >= score_cutoff else 0.0 for v in row]
198
+ for row in matrix
199
+ ]
197
200
  if dtype is not None:
198
201
  try:
199
202
  import numpy as np
@@ -201,9 +204,13 @@ def cdist(
201
204
  except ImportError:
202
205
  pass
203
206
  return matrix
204
- except Exception:
205
- # Fall through to the generic path on any unexpected error.
206
- pass
207
+ except Exception as exc:
208
+ import warnings
209
+ warnings.warn(
210
+ f"fuzzgpu cdist fast path failed ({exc!r}), falling back to per-cell scorer",
211
+ RuntimeWarning,
212
+ stacklevel=2,
213
+ )
207
214
 
208
215
  # ── Generic path ─────────────────────────────────────────────────────
209
216
  def row(query):
@@ -1,43 +0,0 @@
1
- """rapidfuzz.distance.DamerauLevenshtein-compatible module.
2
-
3
- NOTE: This module implements the **unrestricted** Lowrance-Wagner (1975)
4
- algorithm which allows non-adjacent transpositions. rapidfuzz's
5
- DamerauLevenshtein uses Optimal String Alignment (OSA) which forbids them.
6
- For OSA-compatible semantics use fuzzgpu.distance.OSA.
7
- Example difference: distance("ca", "abc") == 2 here (unrestricted),
8
- == 3 in rapidfuzz's OSA-based DamerauLevenshtein.
9
- """
10
- from .. import fuzzgpu as _native
11
- from ._common import cutoff_distance, normalized_distance as _normalized
12
-
13
-
14
- def distance(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
15
- del score_hint
16
- if processor:
17
- s1, s2 = processor(s1), processor(s2)
18
- return cutoff_distance(_native.damerau_levenshtein_distance(s1, s2), score_cutoff)
19
-
20
-
21
- def similarity(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
22
- del score_hint
23
- if processor:
24
- s1, s2 = processor(s1), processor(s2)
25
- maximum = max(len(s1), len(s2))
26
- value = maximum - distance(s1, s2)
27
- return value if score_cutoff is None or value >= score_cutoff else 0
28
-
29
-
30
- def normalized_distance(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
31
- del score_hint
32
- if processor:
33
- s1, s2 = processor(s1), processor(s2)
34
- value = _normalized(distance(s1, s2), max(len(s1), len(s2)))
35
- return value if score_cutoff is None or value <= score_cutoff else 1.0
36
-
37
-
38
- def normalized_similarity(s1, s2, *, processor=None, score_cutoff=None, score_hint=None):
39
- del score_hint
40
- if processor:
41
- s1, s2 = processor(s1), processor(s2)
42
- value = 1.0 - _normalized(distance(s1, s2), max(len(s1), len(s2)))
43
- return value if score_cutoff is None or value >= score_cutoff else 0.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes