zer/lib.rs
1//! `zernified entity resolution library.
2//!
3//! Provides [`Comparator`], [`Scorer`], and a [`Backend`] abstraction that
4//! selects GPU acceleration automatically when compiled with the `cuda` or
5//! `vulkan` features and suitable hardware is present. Without those features
6//! the crate compiles and runs entirely on CPU via `zer-compare`.
7//!
8//! # Quick start
9//!
10//! ```rust,no_run
11//! use zer::prelude::*;
12//!
13//! let schema = SchemaBuilder::new()
14//! .field("naam", FieldKind::Name)
15//! .field("datum", FieldKind::Date)
16//! .build().unwrap();
17//!
18//! let backend = Backend::auto_detect(); // CUDA → Vulkan → AVX2 → CPU
19//! let comparator = Comparator::new(&schema, &backend);
20//! let scorer = Scorer::new(&backend);
21//! ```
22//!
23//! # Feature flags
24//!
25//! **Compute backends** (mutually exclusive in practice; pick one):
26//!
27//! | Flag | Description |
28//! |------------------|--------------------------------------------------------------------------|
29//! | `cuda` | NVIDIA CUDA via `zer-compute`, requires CUDA Toolkit 13.1+ and `nvcc` |
30//! | `vulkan` | Vulkan 1.3 compute via `zer-compute`, requires `slangc` on `PATH` |
31//! | `avx2` | x86_64 AVX2 SIMD via `zer-compute`, no external toolchain required |
32//! | `cpu` | Explicit scalar CPU path via `zer-compute` (Rayon parallel) |
33//! | `debug-shaders` | Embed debug info in CUDA kernels for `cuda-gdb` / Nsight (needs `cuda`) |
34//!
35//! **Pipeline integration:**
36//!
37//! | Flag | Description |
38//! |------------|--------------------------------------------------------------------------|
39//! | `pipeline` | Enable `Pipeline`, `Ingester`, and related types from `zer-pipeline` |
40//!
41//! **Neural judge ORT execution providers** (independent of compute backend):
42//!
43//! | Flag | Description |
44//! |------------------|--------------------------------------------------------------------------|
45//! | `judge_cpu` | Scalar CPU execution provider for ORT (no extra dependencies) |
46//! | `judge_cuda` | NVIDIA CUDA execution provider for ORT |
47//! | `judge_rocm` | AMD ROCm execution provider for ORT |
48//! | `judge_directml` | Windows DirectML execution provider for ORT |
49//! | `judge_openvino` | Intel OpenVINO execution provider for ORT |
50//!
51//! # CPU-only usage
52//!
53//! Users who never need GPU can depend on `zer-compare` directly and never
54//! import this crate. `zer_compare::FieldComparator` and
55//! `zer_compare::FellegiSunterScorer` are the raw CPU implementations.
56
57#[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
58use std::sync::Arc;
59
60use zer_core::{
61 comparison::{ComparisonBatch, ComparisonVector},
62 record::Record,
63 record_pool::RecordPool,
64 schema::Schema,
65 scoring::{ModelParams, ScoredPair},
66 traits::{Comparator as ComparatorTrait, Result as ZerResult, Scorer as ScorerTrait},
67};
68
69// ── Backend ───────────────────────────────────────────────────────────────────
70
71enum BackendInner {
72 Cpu,
73 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
74 Gpu(Arc<zer_compute::DeviceBackend>),
75}
76
77/// Opaque compute backend handle.
78///
79/// Create once and share between [`Comparator`] and [`Scorer`] so both use the
80/// same underlying GPU device.
81///
82/// ```rust,no_run
83/// use zer::prelude::*;
84///
85/// let schema = SchemaBuilder::new().field("naam", FieldKind::Name).build().unwrap();
86/// let backend = Backend::auto_detect();
87/// let comparator = Comparator::new(&schema, &backend);
88/// let scorer = Scorer::new(&backend);
89/// ```
90pub struct Backend {
91 inner: BackendInner,
92 name: &'static str,
93}
94
95impl Backend {
96 /// Read `--target=<name>` from process args and return the matching backend.
97 ///
98 /// Falls back to CPU when the flag is absent, no hardware probing.
99 /// Pass `--target=auto` to restore the hardware-detection order
100 /// (CUDA → Vulkan → AVX2 → CPU).
101 pub fn auto_detect() -> Self {
102 match std::env::args()
103 .find_map(|a| a.strip_prefix("--target=").map(str::to_owned))
104 .as_deref()
105 {
106 Some(t) => Self::from_target(t),
107 None => Self::cpu(),
108 }
109 }
110
111 /// Force the CPU backend regardless of available hardware.
112 pub fn cpu() -> Self {
113 Self {
114 inner: BackendInner::Cpu,
115 name: "cpu",
116 }
117 }
118
119 /// Select a backend by name, called by `auto_detect()` to resolve `--target=<name>`.
120 ///
121 /// Accepted values: `"auto"` (hardware-detect), `"cpu"`, `"cuda"`, `"avx2"`, `"vulkan"`.
122 ///
123 /// Exits with a diagnostic if the target is unknown, not compiled in, or hardware init fails.
124 pub fn from_target(target: &str) -> Self {
125 if target == "cpu" {
126 return Self::cpu();
127 }
128
129 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
130 {
131 let pref = match target {
132 "auto" => zer_compute::BackendPreference::Auto,
133 "cuda" => zer_compute::BackendPreference::Cuda,
134 "vulkan" => zer_compute::BackendPreference::Vulkan,
135 "avx2" => zer_compute::BackendPreference::Avx2,
136 other => {
137 tracing::error!(
138 target = other,
139 "unknown --target; valid: auto, cpu, avx2, cuda, vulkan"
140 );
141 std::process::exit(1);
142 }
143 };
144 return match zer_compute::DeviceBackend::from_preference(pref) {
145 Ok(dev) => {
146 let name = dev.name();
147 if dev.is_accelerated() {
148 Self {
149 inner: BackendInner::Gpu(Arc::new(dev)),
150 name,
151 }
152 } else {
153 Self {
154 inner: BackendInner::Cpu,
155 name: "cpu",
156 }
157 }
158 }
159 Err(e) => {
160 tracing::error!(target, error = %e, "--target unavailable");
161 std::process::exit(1);
162 }
163 };
164 }
165
166 #[allow(unreachable_code)]
167 {
168 if target == "auto" {
169 return Self::cpu();
170 }
171 tracing::error!(
172 target,
173 "unknown --target; valid values when built without GPU features: auto, cpu"
174 );
175 std::process::exit(1);
176 }
177 }
178
179 /// Human-readable name of the active backend: `"cpu"`, `"cuda"`, or `"avx2"`.
180 pub fn name(&self) -> &'static str {
181 self.name
182 }
183
184 /// `true` when a GPU backend is active.
185 pub fn is_gpu(&self) -> bool {
186 !matches!(self.inner, BackendInner::Cpu)
187 }
188}
189
190// ── Comparator ────────────────────────────────────────────────────────────────
191
192enum ComparatorInner {
193 Cpu(zer_compare::FieldComparator),
194 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
195 Gpu(zer_compute::DeviceComparator),
196}
197
198/// Pairwise record comparator with automatic GPU/CPU selection.
199///
200/// Wraps `FieldComparator` (CPU) or `DeviceComparator` (GPU) depending on the
201/// [`Backend`]. Implements [`ComparatorTrait`] identically in both cases.
202pub struct Comparator {
203 inner: ComparatorInner,
204}
205
206impl Comparator {
207 /// Wrap an already-constructed [`zer_compare::FieldComparator`] directly.
208 ///
209 /// Use this when you want to override default similarity functions via
210 /// [`zer_compare::FieldComparator::with_fns`] before creating the comparator.
211 /// Always uses the CPU path; GPU acceleration is not available this way.
212 pub fn from_cpu(fc: zer_compare::FieldComparator) -> Self {
213 Self {
214 inner: ComparatorInner::Cpu(fc),
215 }
216 }
217
218 /// Build a comparator from a schema and backend.
219 pub fn new(schema: &Schema, backend: &Backend) -> Self {
220 match &backend.inner {
221 BackendInner::Cpu => Self {
222 inner: ComparatorInner::Cpu(zer_compare::FieldComparator::from_schema(schema)),
223 },
224 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
225 BackendInner::Gpu(dev) => Self {
226 inner: ComparatorInner::Gpu(
227 zer_compute::DeviceComparator::new(Arc::clone(dev), schema).unwrap(),
228 ),
229 },
230 }
231 }
232
233 /// Name of the active backend, for diagnostics.
234 pub fn backend_name(&self) -> &'static str {
235 match &self.inner {
236 ComparatorInner::Cpu(_) => "cpu",
237 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
238 ComparatorInner::Gpu(c) => c.backend_name(),
239 }
240 }
241
242 /// Primary hot-path: pool-native batch comparison.
243 ///
244 /// `pool` is a `RecordPool` built from the candidate records; `pair_indices`
245 /// holds `(i, j)` pairs where `i` and `j` are indices into the pool.
246 /// Avoids all `Record::clone()` and `HashMap` lookups, the fastest path for
247 /// large BRP-style jobs where records are already loaded into a pool.
248 pub fn compare_batch_from_pool(
249 &self,
250 pool: &RecordPool,
251 pair_indices: &[(usize, usize)],
252 schema: &Schema,
253 ) -> ComparisonBatch {
254 match &self.inner {
255 ComparatorInner::Cpu(c) => c.compare_batch_from_pool(pool, pair_indices, schema),
256 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
257 ComparatorInner::Gpu(c) => c.compare_batch_from_pool(pool, pair_indices, schema),
258 }
259 }
260
261 /// Convenience wrapper: builds a pool from a flat `records` slice and compares
262 /// the `pair_indices` pairs. No `Record::clone()`.
263 pub fn compare_batch_indexed(
264 &self,
265 records: &[Record],
266 pair_indices: &[(usize, usize)],
267 schema: &Schema,
268 ) -> ComparisonBatch {
269 let pool = RecordPool::from_records(records, schema);
270 self.compare_batch_from_pool(&pool, pair_indices, schema)
271 }
272}
273
274impl ComparatorTrait for Comparator {
275 fn compare(&self, a: &Record, b: &Record, schema: &Schema) -> ComparisonVector {
276 match &self.inner {
277 ComparatorInner::Cpu(c) => c.compare(a, b, schema),
278 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
279 ComparatorInner::Gpu(c) => c.compare(a, b, schema),
280 }
281 }
282
283 fn compare_batch_from_pool(
284 &self,
285 pool: &RecordPool,
286 indices: &[(usize, usize)],
287 schema: &Schema,
288 ) -> ComparisonBatch {
289 self.compare_batch_from_pool(pool, indices, schema)
290 }
291}
292
293// ── Scorer ────────────────────────────────────────────────────────────────────
294
295enum ScorerInner {
296 Cpu(zer_compare::FellegiSunterScorer),
297 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
298 Gpu(zer_compute::DeviceScorer),
299}
300
301/// Fellegi-Sunter scorer with automatic GPU/CPU EM acceleration.
302///
303/// `score` / `score_batch` always run on CPU, no kernel overhead for small
304/// operations. `estimate_params` uses the GPU EM kernel when the backend is
305/// GPU and the batch exceeds the transfer break-even threshold; otherwise it
306/// falls back to `zer_compare::run_em` on the CPU.
307pub struct Scorer {
308 inner: ScorerInner,
309}
310
311impl Scorer {
312 /// Build a scorer using the given backend.
313 pub fn new(backend: &Backend) -> Self {
314 match &backend.inner {
315 BackendInner::Cpu => Self {
316 inner: ScorerInner::Cpu(zer_compare::FellegiSunterScorer),
317 },
318 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
319 BackendInner::Gpu(dev) => Self {
320 inner: ScorerInner::Gpu(zer_compute::DeviceScorer::new(Arc::clone(dev))),
321 },
322 }
323 }
324
325 /// Name of the active backend, for diagnostics.
326 pub fn backend_name(&self) -> &'static str {
327 match &self.inner {
328 ScorerInner::Cpu(_) => "cpu",
329 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
330 ScorerInner::Gpu(s) => s.backend_name(),
331 }
332 }
333}
334
335impl ScorerTrait for Scorer {
336 fn score(&self, vector: &ComparisonVector, params: &ModelParams) -> ScoredPair {
337 match &self.inner {
338 ScorerInner::Cpu(s) => s.score(vector, params),
339 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
340 ScorerInner::Gpu(s) => s.score(vector, params),
341 }
342 }
343
344 fn score_batch(&self, batch: &ComparisonBatch, params: &ModelParams) -> Vec<ScoredPair> {
345 match &self.inner {
346 ScorerInner::Cpu(s) => s.score_batch(batch, params),
347 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
348 ScorerInner::Gpu(s) => s.score_batch(batch, params),
349 }
350 }
351
352 fn estimate_params(
353 &self,
354 batch: &ComparisonBatch,
355 init: Option<ModelParams>,
356 max_iter: usize,
357 ) -> ZerResult<ModelParams> {
358 match &self.inner {
359 ScorerInner::Cpu(s) => s.estimate_params(batch, init, max_iter),
360 #[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
361 ScorerInner::Gpu(s) => s.estimate_params(batch, init, max_iter),
362 }
363 }
364}
365
366// ── Low-level kernel access for power users ───────────────────────────────────
367
368/// Raw GPU kernel dispatch, for users writing custom kernels.
369///
370/// Requires the `cuda` or `avx2` feature. Most users should use
371/// [`Comparator`] and [`Scorer`] instead.
372///
373/// # Writing a custom kernel
374///
375/// 1. Define a zero-sized marker struct and `impl Kernel for It`.
376/// 2. `impl KernelDispatch<It> for zer_compute::backend::cpu::CpuDevice`, CPU fallback.
377/// 3. `impl KernelDispatch<It> for zer_compute::backend::cuda::CudaDevice`, CUDA path.
378/// 4. Add the `impl KernelDispatch<It> for DeviceBackend` match in
379/// `zer_compute::backend::mod`.
380/// 5. Access the raw device via `zer::compute::DeviceBackend`.
381#[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
382pub mod kernel {
383 pub use zer_compute::{
384 backend::DeviceBackend,
385 error::GpuError,
386 kernel::{Kernel, KernelDispatch},
387 };
388}
389
390// ── Crate re-exports ──────────────────────────────────────────────────────────
391
392pub use zer_blocking as blocking;
393pub use zer_cluster as cluster;
394pub use zer_compare as compare;
395pub use zer_core as core;
396pub use zer_schema as schema;
397
398#[cfg(feature = "pipeline")]
399pub use zer_pipeline as pipeline;
400
401#[cfg(any(feature = "cuda", feature = "avx2", feature = "vulkan"))]
402pub use zer_compute as compute;
403
404// ── Prelude ───────────────────────────────────────────────────────────────────
405
406pub mod prelude {
407 // Concrete auto-detecting types, primary user-facing API
408 pub use crate::{Backend, Comparator, Scorer};
409
410 // Core data types
411 pub use zer_core::{
412 comparison::{ComparisonBatch, ComparisonLevel, ComparisonVector},
413 entity::{Entity, EntityId, EntityMember, ResolutionMethod},
414 error::ZerError,
415 record::{FieldValue, Record, RecordId},
416 record_pool::RecordPool,
417 schema::{FieldKind, Schema, SchemaBuilder},
418 scoring::{MatchBand, ModelParams, ScoredPair},
419 traits::{
420 BlockIndex,
421 Blocker,
422 Clusterer,
423 // Renamed to avoid shadowing the concrete Comparator / Scorer structs above
424 Comparator as ComparatorTrait,
425 EntityStore,
426 Judge,
427 JudgeVerdict,
428 RecordStore,
429 Scorer as ScorerTrait,
430 },
431 VecRecordStore,
432 };
433
434 // Blocking
435 pub use zer_blocking::{
436 keys::{
437 AddressInitialKey, AliasPhoneticKey, CameraTimeWindowKey, DateFragmentKey,
438 DateGranularity, DocumentDigitSuffixKey, DocumentSuffixKey, ExactFieldKey,
439 FuzzyYearKey, GeoGridKey, LicensePlateNormKey, PhoneticAlgo, PhoneticNameDobKey,
440 PlateOCRFuzzyKey, SuffixKey, TransliteratedPhoneticKey,
441 },
442 BlockerFactory, CompositeBlocker, InvertedIndex, SchemaCategory,
443 };
444
445 // CPU implementations, available directly for users who want the raw types
446 pub use zer_compare::{
447 AddressTokenOverlap, FellegiSunterScorer, FieldComparator, JaroWinklerSimilarity,
448 LevelThresholds, PhoneticEqualitySimilarity, SimilarityFn, StreetNumberEditDistance,
449 TokenOverlapSimilarity,
450 };
451
452 // Schema registry and artifact management (Phase 6)
453 pub use zer_schema::{
454 ModelArtifact, SchemaFingerprint, SchemaInferrer, SchemaRegistry, StartupMode,
455 };
456
457 // Clustering and entity store (Phase 6)
458 pub use zer_cluster::{ClusterConfig, ConnectedComponentsClusterer, ZalEntityStore};
459
460 // Pipeline types, available with the `pipeline` feature (no polars required)
461 #[cfg(feature = "pipeline")]
462 pub use zer_pipeline::{
463 BatchReport, ClusterIter, ClusterView, IngestResult, Ingester, Pipeline, PipelineBuilder,
464 PipelineConfig, RateConfig,
465 };
466}