fuzzgpu 0.1.6__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.6 → fuzzgpu-0.1.8}/Cargo.lock +2 -2
  2. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/PKG-INFO +106 -31
  3. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/README.md +105 -30
  4. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/Cargo.toml +1 -1
  5. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/damerau.rs +3 -1
  6. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/jaro.rs +3 -1
  7. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/levenshtein.rs +5 -1
  8. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/needleman.rs +3 -1
  9. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/jaro.wgsl +12 -3
  10. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/jaro_matrix.wgsl +13 -3
  11. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/simd.rs +19 -6
  12. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-python/Cargo.toml +2 -2
  13. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/Cargo.toml +1 -1
  14. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/src/lib.rs +4 -4
  15. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/pyproject.toml +1 -1
  16. fuzzgpu-0.1.8/python/fuzzgpu/distance/DamerauLevenshtein.py +145 -0
  17. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Jaro.py +3 -3
  18. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/JaroWinkler.py +3 -3
  19. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/fuzz.py +54 -3
  20. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/process.py +21 -14
  21. fuzzgpu-0.1.6/python/fuzzgpu/distance/DamerauLevenshtein.py +0 -43
  22. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/Cargo.toml +0 -0
  23. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/LICENSE +0 -0
  24. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/assets/logo.png +0 -0
  25. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/benches/bench.rs +0 -0
  26. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/fuzz.rs +0 -0
  27. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/gpu.rs +0 -0
  28. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/lib.rs +0 -0
  29. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/damerau.wgsl +0 -0
  30. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/damerau_matrix.wgsl +0 -0
  31. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein.wgsl +0 -0
  32. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_cdist_myers.wgsl +0 -0
  33. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_matrix.wgsl +0 -0
  34. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_myers.wgsl +0 -0
  35. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/levenshtein_short.wgsl +0 -0
  36. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/needleman_affine.wgsl +0 -0
  37. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/src/shaders/needleman_wavefront.wgsl +0 -0
  38. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/differential.proptest-regressions +0 -0
  39. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/differential.rs +0 -0
  40. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/fixtures/broken.wgsl +0 -0
  41. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-core/tests/kernel_registration.rs +0 -0
  42. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-python/src/lib.rs +0 -0
  43. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/tests/differential_harness.js +0 -0
  44. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/crates/fuzzgpu-wasm/tests/js_api.test.cjs +0 -0
  45. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/__init__.py +0 -0
  46. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/__init__.pyi +0 -0
  47. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Hamming.py +0 -0
  48. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Indel.py +0 -0
  49. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/LCSseq.py +0 -0
  50. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Levenshtein.py +0 -0
  51. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/OSA.py +0 -0
  52. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Postfix.py +0 -0
  53. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/Prefix.py +0 -0
  54. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/__init__.py +0 -0
  55. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/__init__.pyi +0 -0
  56. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/distance/_common.py +0 -0
  57. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/fuzz.pyi +0 -0
  58. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/python/fuzzgpu/process.pyi +0 -0
  59. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_api_signatures.py +0 -0
  60. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_basic.py +0 -0
  61. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_concurrency.py +0 -0
  62. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_edge_cases.py +0 -0
  63. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_invariants.py +0 -0
  64. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_out_buffers.py +0 -0
  65. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_rapidfuzz_compat.py +0 -0
  66. {fuzzgpu-0.1.6 → fuzzgpu-0.1.8}/tests/test_stress.py +0 -0
  67. {fuzzgpu-0.1.6 → 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.6"
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.6"
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.6
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.6-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,34 +60,82 @@ No NVIDIA CUDA drivers or complex toolkits required.
60
60
 
61
61
  ---
62
62
 
63
- ## What's New in v0.1.6
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>
105
+
106
+ ### Bug fixes (Windows DX12 / Jaro shader)
107
+ - **Jaro GPU shader FXC crash fixed** — `jaro.wgsl` and `jaro_matrix.wgsl` used dynamic vector
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).
110
+
111
+ ### Bug fixes (arity mismatch — wasm & fuzz crates)
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.
115
+
116
+ </details>
117
+
118
+ <details>
119
+ <summary><b>Previous (v0.1.6)</b></summary>
64
120
 
65
121
  ### Drop-in rapidfuzz parity (Python)
66
122
  The full Python layer is now byte-identical to rapidfuzz 3.14.5 over a 169,744-pair differential harness across `ratio`, `partial_ratio`, `token_sort_ratio`, `token_set_ratio`, `token_ratio`, `WRatio`, `QRatio`, `partial_token_*`, `jaro`, `jaro_winkler`, `levenshtein`, `indel`, `hamming`, `osa` — **0 mismatches**.
67
123
 
68
124
  ### Bug fixes (Rust core, float parity)
69
- - **`ratio` / `partial_ratio` cutoff imprecision** — port of rapidfuzz's load-bearing `NormSim_to_NormDist = min(1, 1 - cutoff/100 + 1e-5)` term. Without it, the branch-and-bound would silently reject ties at exact cutoffs (e.g. `partial_ratio("park", "ba", score_cutoff=50.0)` returned `0` instead of `50`).
70
- - **`ratio` score formula** — switched from `((len-dist)/len)*100` to `(1 - dist/len)*100` to match rapidfuzz C++'s exact ulp order (`indel_normalized_similarity * 100`).
125
+ - **`ratio` / `partial_ratio` cutoff imprecision** — port of rapidfuzz's load-bearing `NormSim_to_NormDist = min(1, 1 - cutoff/100 + 1e-5)` term.
126
+ - **`ratio` score formula** — switched from `((len-dist)/len)*100` to `(1 - dist/len)*100` to match rapidfuzz C++'s exact ulp order.
71
127
 
72
128
  ### New features (Python distance layer)
73
- - **`Editops` / `Opcodes` / `Editop` / `Opcode` / `MatchingBlock` / `ScoreAlignment`** classes (rapidfuzz-compatible list-/tuple-likes with `as_list`, `as_opcodes`, `as_editops`, `as_matching_blocks`, `apply`, `inverse`, `remove_subsequence`, `from_*`).
74
- - **`Levenshtein.editops` / `.opcodes`** exact Myers bit-parallel port with `common_affix` (suffix measured on post-prefix slice, matching rapidfuzz).
75
- - **`LCSseq`** module Myers LCS bit-parallel matrix + editops/opcodes (delete-checked-first traceback).
76
- - **`Prefix`** and **`Postfix`** modules with the C++ `1 - dist/maximum` ulp order.
77
- - **`Hamming.editops` / `.opcodes`** — replace-per-mismatch + padding delete/insert model.
78
- - **`Indel.editops` / `.opcodes`** — delegates to `LCSseq` (matching rapidfuzz C++).
79
- - **`fuzz.partial_ratio_alignment`** now returns `ScoreAlignment(score, src_start, src_end, dest_start, dest_end)` — drop-in compatible with rapidfuzz.
80
- - **`process.extract` / `extractOne` / `cdist`** default to `WRatio` (matches rapidfuzz 3.14.5).
81
- - **`token_ratio` / `partial_token_ratio`** exposed at top level (`fuzzgpu.token_ratio`, `fuzzgpu.partial_token_ratio`).
82
- - All alignment types re-exported at the package root (`fuzzgpu.Editop`, `fuzzgpu.Editops`, etc.).
83
-
84
- ### Type stubs
85
- - `__init__.pyi`, `fuzz.pyi`, `process.pyi`, `distance/__init__.pyi` updated for the new APIs.
129
+ - **`Editops` / `Opcodes` / `Editop` / `Opcode` / `MatchingBlock` / `ScoreAlignment`** classes (rapidfuzz-compatible).
130
+ - **`Levenshtein.editops` / `.opcodes`**, **`LCSseq`**, **`Prefix`**, **`Postfix`**, **`Hamming.editops`**, **`Indel.editops`** modules.
131
+ - **`fuzz.partial_ratio_alignment`** returns `ScoreAlignment` (rapidfuzz-compatible).
132
+ - **`process.extract` / `extractOne` / `cdist`** default to `WRatio`.
133
+ - All alignment types re-exported at the package root.
86
134
 
87
- ---
135
+ </details>
88
136
 
89
137
  <details>
90
- <summary><b>Previous (v0.1.5</b</summary>
138
+ <summary><b>Previous (v0.1.5)</b></summary>
91
139
 
92
140
  ### Bug fixes
93
141
  - **Damerau-Levenshtein safety gate** now fires in release builds (`assert!` not `debug_assert!`) — non-ASCII inputs no longer silently produce wrong distances in production wheels
@@ -116,26 +164,53 @@ The full Python layer is now byte-identical to rapidfuzz 3.14.5 over a 169,744-p
116
164
  ## Benchmark Results
117
165
 
118
166
  *Hardware: Intel(R) Iris(R) Xe Graphics (Vulkan) + Intel Core i7 (Rayon uses all cores)*
119
- *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*
120
168
  *Median of 7 runs after warmup. Reproduce: `python benchmarks/bench_compare.py`*
121
169
 
122
170
  ### Levenshtein Batch (1 query × N candidates, 10-char strings)
123
171
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
124
172
  | :--- | :---: | :---: | :---: | :---: | :---: |
125
- | **100** | 0.12 ms | 0.01 ms | 0.03 ms | 0.25× | 2.21× |
126
- | **1,000** | 0.83 ms | 0.09 ms | 0.15 ms | 0.18× | 1.67× |
127
- | **10,000** | 2.60 ms | 0.87 ms | 1.47 ms | 0.57× | 1.68× |
128
- | **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× |
129
177
 
130
178
  ### Damerau-Levenshtein Batch (unrestricted Lowrance-Wagner)
131
179
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
132
180
  | :--- | :---: | :---: | :---: | :---: | :---: |
133
- | **1,000** | 0.58 ms | 0.40 ms | 1.63 ms | 2.81× | 4.06× |
134
- | **10,000** | 2.95 ms | 2.15 ms | 20.86 ms | 7.06× | 9.70× |
135
- | **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× |
136
185
 
137
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.
138
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
+
139
214
  ---
140
215
 
141
216
  ## Installation
@@ -147,7 +222,7 @@ pip install fuzzgpu
147
222
  ```toml
148
223
  # Rust
149
224
  [dependencies]
150
- fuzzgpu-core = "0.1.6"
225
+ fuzzgpu-core = "0.1.8"
151
226
  ```
152
227
 
153
228
  ---
@@ -243,8 +318,8 @@ fuzzgpu.set_cpu_only(True) # force CPU-only mode
243
318
 
244
319
  ```toml
245
320
  [dependencies]
246
- fuzzgpu-core = "0.1.6" # GPU + CPU fallback
247
- # fuzzgpu-core = { version = "0.1.6", default-features = false } # CPU-only
321
+ fuzzgpu-core = "0.1.8" # GPU + CPU fallback
322
+ # fuzzgpu-core = { version = "0.1.7", default-features = false } # CPU-only
248
323
  ```
249
324
 
250
325
  ```rust
@@ -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.6-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,34 +31,82 @@ No NVIDIA CUDA drivers or complex toolkits required.
31
31
 
32
32
  ---
33
33
 
34
- ## What's New in v0.1.6
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>
76
+
77
+ ### Bug fixes (Windows DX12 / Jaro shader)
78
+ - **Jaro GPU shader FXC crash fixed** — `jaro.wgsl` and `jaro_matrix.wgsl` used dynamic vector
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).
81
+
82
+ ### Bug fixes (arity mismatch — wasm & fuzz crates)
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.
86
+
87
+ </details>
88
+
89
+ <details>
90
+ <summary><b>Previous (v0.1.6)</b></summary>
35
91
 
36
92
  ### Drop-in rapidfuzz parity (Python)
37
93
  The full Python layer is now byte-identical to rapidfuzz 3.14.5 over a 169,744-pair differential harness across `ratio`, `partial_ratio`, `token_sort_ratio`, `token_set_ratio`, `token_ratio`, `WRatio`, `QRatio`, `partial_token_*`, `jaro`, `jaro_winkler`, `levenshtein`, `indel`, `hamming`, `osa` — **0 mismatches**.
38
94
 
39
95
  ### Bug fixes (Rust core, float parity)
40
- - **`ratio` / `partial_ratio` cutoff imprecision** — port of rapidfuzz's load-bearing `NormSim_to_NormDist = min(1, 1 - cutoff/100 + 1e-5)` term. Without it, the branch-and-bound would silently reject ties at exact cutoffs (e.g. `partial_ratio("park", "ba", score_cutoff=50.0)` returned `0` instead of `50`).
41
- - **`ratio` score formula** — switched from `((len-dist)/len)*100` to `(1 - dist/len)*100` to match rapidfuzz C++'s exact ulp order (`indel_normalized_similarity * 100`).
96
+ - **`ratio` / `partial_ratio` cutoff imprecision** — port of rapidfuzz's load-bearing `NormSim_to_NormDist = min(1, 1 - cutoff/100 + 1e-5)` term.
97
+ - **`ratio` score formula** — switched from `((len-dist)/len)*100` to `(1 - dist/len)*100` to match rapidfuzz C++'s exact ulp order.
42
98
 
43
99
  ### New features (Python distance layer)
44
- - **`Editops` / `Opcodes` / `Editop` / `Opcode` / `MatchingBlock` / `ScoreAlignment`** classes (rapidfuzz-compatible list-/tuple-likes with `as_list`, `as_opcodes`, `as_editops`, `as_matching_blocks`, `apply`, `inverse`, `remove_subsequence`, `from_*`).
45
- - **`Levenshtein.editops` / `.opcodes`** exact Myers bit-parallel port with `common_affix` (suffix measured on post-prefix slice, matching rapidfuzz).
46
- - **`LCSseq`** module Myers LCS bit-parallel matrix + editops/opcodes (delete-checked-first traceback).
47
- - **`Prefix`** and **`Postfix`** modules with the C++ `1 - dist/maximum` ulp order.
48
- - **`Hamming.editops` / `.opcodes`** — replace-per-mismatch + padding delete/insert model.
49
- - **`Indel.editops` / `.opcodes`** — delegates to `LCSseq` (matching rapidfuzz C++).
50
- - **`fuzz.partial_ratio_alignment`** now returns `ScoreAlignment(score, src_start, src_end, dest_start, dest_end)` — drop-in compatible with rapidfuzz.
51
- - **`process.extract` / `extractOne` / `cdist`** default to `WRatio` (matches rapidfuzz 3.14.5).
52
- - **`token_ratio` / `partial_token_ratio`** exposed at top level (`fuzzgpu.token_ratio`, `fuzzgpu.partial_token_ratio`).
53
- - All alignment types re-exported at the package root (`fuzzgpu.Editop`, `fuzzgpu.Editops`, etc.).
54
-
55
- ### Type stubs
56
- - `__init__.pyi`, `fuzz.pyi`, `process.pyi`, `distance/__init__.pyi` updated for the new APIs.
100
+ - **`Editops` / `Opcodes` / `Editop` / `Opcode` / `MatchingBlock` / `ScoreAlignment`** classes (rapidfuzz-compatible).
101
+ - **`Levenshtein.editops` / `.opcodes`**, **`LCSseq`**, **`Prefix`**, **`Postfix`**, **`Hamming.editops`**, **`Indel.editops`** modules.
102
+ - **`fuzz.partial_ratio_alignment`** returns `ScoreAlignment` (rapidfuzz-compatible).
103
+ - **`process.extract` / `extractOne` / `cdist`** default to `WRatio`.
104
+ - All alignment types re-exported at the package root.
57
105
 
58
- ---
106
+ </details>
59
107
 
60
108
  <details>
61
- <summary><b>Previous (v0.1.5</b</summary>
109
+ <summary><b>Previous (v0.1.5)</b></summary>
62
110
 
63
111
  ### Bug fixes
64
112
  - **Damerau-Levenshtein safety gate** now fires in release builds (`assert!` not `debug_assert!`) — non-ASCII inputs no longer silently produce wrong distances in production wheels
@@ -87,26 +135,53 @@ The full Python layer is now byte-identical to rapidfuzz 3.14.5 over a 169,744-p
87
135
  ## Benchmark Results
88
136
 
89
137
  *Hardware: Intel(R) Iris(R) Xe Graphics (Vulkan) + Intel Core i7 (Rayon uses all cores)*
90
- *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*
91
139
  *Median of 7 runs after warmup. Reproduce: `python benchmarks/bench_compare.py`*
92
140
 
93
141
  ### Levenshtein Batch (1 query × N candidates, 10-char strings)
94
142
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
95
143
  | :--- | :---: | :---: | :---: | :---: | :---: |
96
- | **100** | 0.12 ms | 0.01 ms | 0.03 ms | 0.25× | 2.21× |
97
- | **1,000** | 0.83 ms | 0.09 ms | 0.15 ms | 0.18× | 1.67× |
98
- | **10,000** | 2.60 ms | 0.87 ms | 1.47 ms | 0.57× | 1.68× |
99
- | **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× |
100
148
 
101
149
  ### Damerau-Levenshtein Batch (unrestricted Lowrance-Wagner)
102
150
  | Batch Size | `fuzzgpu` (GPU) | `fuzzgpu` (CPU) | `rapidfuzz` | vs RF (GPU) | vs RF (CPU) |
103
151
  | :--- | :---: | :---: | :---: | :---: | :---: |
104
- | **1,000** | 0.58 ms | 0.40 ms | 1.63 ms | 2.81× | 4.06× |
105
- | **10,000** | 2.95 ms | 2.15 ms | 20.86 ms | 7.06× | 9.70× |
106
- | **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× |
107
156
 
108
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.
109
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
+
110
185
  ---
111
186
 
112
187
  ## Installation
@@ -118,7 +193,7 @@ pip install fuzzgpu
118
193
  ```toml
119
194
  # Rust
120
195
  [dependencies]
121
- fuzzgpu-core = "0.1.6"
196
+ fuzzgpu-core = "0.1.8"
122
197
  ```
123
198
 
124
199
  ---
@@ -214,8 +289,8 @@ fuzzgpu.set_cpu_only(True) # force CPU-only mode
214
289
 
215
290
  ```toml
216
291
  [dependencies]
217
- fuzzgpu-core = "0.1.6" # GPU + CPU fallback
218
- # fuzzgpu-core = { version = "0.1.6", default-features = false } # CPU-only
292
+ fuzzgpu-core = "0.1.8" # GPU + CPU fallback
293
+ # fuzzgpu-core = { version = "0.1.7", default-features = false } # CPU-only
219
294
  ```
220
295
 
221
296
  ```rust
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "fuzzgpu-core"
3
- version = "0.1.6"
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> {
@@ -51,10 +51,19 @@ fn bit_test(mb: vec4<u32>, j: u32) -> bool {
51
51
  }
52
52
 
53
53
  // Return the bitmap with bit j set.
54
+ // Uses select() instead of dynamic vector l-value indexing (v[expr] = ...)
55
+ // because FXC (DX12 HLSL compiler) does not support dynamic vector component
56
+ // writes — it cannot emit a register-indexed store for a non-constant index
57
+ // and fails with X3550/X3511 when it tries to unroll around it.
54
58
  fn bit_set(mb: vec4<u32>, j: u32) -> vec4<u32> {
55
- var v = mb;
56
- v[j >> 5u] = v[j >> 5u] | (1u << (j & 31u));
57
- return v;
59
+ let word = j >> 5u;
60
+ let bit = 1u << (j & 31u);
61
+ return vec4<u32>(
62
+ mb.x | select(0u, bit, word == 0u),
63
+ mb.y | select(0u, bit, word == 1u),
64
+ mb.z | select(0u, bit, word == 2u),
65
+ mb.w | select(0u, bit, word == 3u),
66
+ );
58
67
  }
59
68
 
60
69
  @compute @workgroup_size(64)
@@ -25,10 +25,20 @@ fn bit_test(mb: vec4<u32>, j: u32) -> bool {
25
25
  return ((mb[j >> 5u] >> (j & 31u)) & 1u) != 0u;
26
26
  }
27
27
 
28
+ // Return the bitmap with bit j set.
29
+ // Uses select() instead of dynamic vector l-value indexing (v[expr] = ...)
30
+ // because FXC (DX12 HLSL compiler) does not support dynamic vector component
31
+ // writes — it cannot emit a register-indexed store for a non-constant index
32
+ // and fails with X3550/X3511 when it tries to unroll around it.
28
33
  fn bit_set(mb: vec4<u32>, j: u32) -> vec4<u32> {
29
- var v = mb;
30
- v[j >> 5u] = v[j >> 5u] | (1u << (j & 31u));
31
- return v;
34
+ let word = j >> 5u;
35
+ let bit = 1u << (j & 31u);
36
+ return vec4<u32>(
37
+ mb.x | select(0u, bit, word == 0u),
38
+ mb.y | select(0u, bit, word == 1u),
39
+ mb.z | select(0u, bit, word == 2u),
40
+ mb.w | select(0u, bit, word == 3u),
41
+ );
32
42
  }
33
43
 
34
44
  @compute @workgroup_size(16, 16)
@@ -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.6"
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.6", 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.6"
3
+ version = "0.1.8"
4
4
  edition = "2021"
5
5
 
6
6
  [workspace]
@@ -57,22 +57,22 @@ pub fn ratio(a: &str, b: &str) -> f64 {
57
57
 
58
58
  #[wasm_bindgen]
59
59
  pub fn partial_ratio(a: &str, b: &str) -> f64 {
60
- fuzzgpu_core::partial_ratio(a, b)
60
+ fuzzgpu_core::partial_ratio(a, b, 0.0)
61
61
  }
62
62
 
63
63
  #[wasm_bindgen]
64
64
  pub fn token_sort_ratio(a: &str, b: &str) -> f64 {
65
- fuzzgpu_core::token_sort_ratio(a, b)
65
+ fuzzgpu_core::token_sort_ratio(a, b, 0.0)
66
66
  }
67
67
 
68
68
  #[wasm_bindgen]
69
69
  pub fn token_set_ratio(a: &str, b: &str) -> f64 {
70
- fuzzgpu_core::token_set_ratio(a, b)
70
+ fuzzgpu_core::token_set_ratio(a, b, 0.0)
71
71
  }
72
72
 
73
73
  #[wasm_bindgen]
74
74
  pub fn wratio(a: &str, b: &str) -> f64 {
75
- fuzzgpu_core::wratio(a, b)
75
+ fuzzgpu_core::wratio(a, b, 0.0)
76
76
  }
77
77
 
78
78
  #[wasm_bindgen]
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "fuzzgpu"
7
- version = "0.1.6"
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