imgfprint 0.3.2

High-performance, deterministic image fingerprinting library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
# `imgfprint-rs` โ€” Production-Grade Code Review

**Project:** `imgfprint` v0.3.1 / v0.3.2-unreleased  
**Language:** Rust 2021 Edition  
**Summary:** High-performance image fingerprinting library (AHash, PHash, DHash, BLAKE3, Semantic Embeddings)

---

## Executive Summary

The codebase is well-structured and shows strong engineering discipline โ€” cache-aligned structs, SIMD acceleration, pre-computed lookup tables, constant-time comparisons, and thoughtful feature gating. However, several correctness bugs, API design inconsistencies, safety footguns, and test coverage gaps need to be addressed before this can be considered truly production-ready.

Issues are ranked **๐Ÿ”ด Critical**, **๐ŸŸ  High**, **๐ŸŸก Medium**, **๐ŸŸข Low / Polish**.

---

## ๐Ÿ”ด Critical Issues

### 1. DCT-II Implementation is Mathematically Incorrect (`src/hash/phash.rs`)

**File:** `src/hash/phash.rs`, function `dct2_32`

The DCT-II via real-FFT algorithm is incomplete. The standard algorithm requires multiplying each spectral coefficient by a twiddle factor `exp(-jยทฯ€ยทk/(2ยทN))` before taking the real part. The code skips this and uses `complex_buffer[k].re` directly:

```rust
// CURRENT โ€” WRONG: missing twiddle factors
for (i, item) in output.iter_mut().enumerate().take(32).skip(1) {
    let k = i.min(32 - i);  // also wrong: produces 1,2,3..16,15..1, not 1..31
    *item = complex_buffer[k].re * SCALE;
}
```

**What this means:** PHash is computing a "pseudo-DCT" โ€” not the real DCT-II. Hashes are still deterministic and self-consistent, so comparisons work, but the claim of "DCT-based, robust to compression" cannot be validated. The algorithm's frequency-domain properties are unknown. Any user depending on cross-library PHash compatibility will get wrong results.

**Fix:**
```rust
use std::f32::consts::PI;
// Correct extraction with twiddle factors
output[0] = complex_buffer[0].re * SCALE;
for k in 1..32 {
    let angle = -PI * k as f32 / (2.0 * 32.0);
    let twiddle_re = angle.cos();
    let twiddle_im = angle.sin();
    let re = complex_buffer[k.min(32 - k)].re;
    let im = if k < 17 { complex_buffer[k].im } else { -complex_buffer[32 - k].im };
    output[k] = (re * twiddle_re - im * twiddle_im) * SCALE;
}
```
**Note:** Fix will change all existing PHash values โ€” this is a breaking API change requiring a version bump to 0.4.0.

---

### 2. Duplicate Thread-Local Contexts โ€” Double Memory, Subtle Correctness Risk (`src/core/fingerprinter.rs`)

`ImageFingerprinter::fingerprint()` and `ImageFingerprinter::fingerprint_with()` each declare their own independent `thread_local!`:

```rust
// In fingerprint()
thread_local! {
    static CTX: RefCell<FingerprinterContext> = RefCell::new(FingerprinterContext::new());
}
// In fingerprint_with()  โ€” completely separate TLS slot!
thread_local! {
    static CTX: RefCell<FingerprinterContext> = RefCell::new(FingerprinterContext::new());
}
```

Every thread carries **two** independent `FingerprinterContext` objects with their own `Preprocessor` (including `Resizer` + two `Vec` buffers). This doubles per-thread memory and prevents shared buffer reuse. Worse, code that mixes both call sites never benefits from the context caching.

**Fix:** Extract a single module-level TLS and share it:
```rust
thread_local! {
    static SHARED_CTX: RefCell<FingerprinterContext> = RefCell::new(FingerprinterContext::new());
}

impl ImageFingerprinter {
    pub fn fingerprint(image_bytes: &[u8]) -> Result<MultiHashFingerprint, ImgFprintError> {
        SHARED_CTX.with(|ctx| ctx.borrow_mut().fingerprint(image_bytes))
    }
    pub fn fingerprint_with(image_bytes: &[u8], algorithm: HashAlgorithm)
        -> Result<ImageFingerprint, ImgFprintError>
    {
        SHARED_CTX.with(|ctx| ctx.borrow_mut().fingerprint_with(image_bytes, algorithm))
    }
}
```

---

### 3. Missing OOM / DoS Guard in `decode_image` (`src/imgproc/decode.rs`)

The README claims "OOM protection (8192px max)" but the actual `decode_image` function visible in the codebase does not show an explicit dimension check **before decompression**. A maliciously crafted compressed image (e.g., a tiny PNG that expands to a massive bitmap) can exhaust memory before the check fires.

**Fix:** Check dimensions before converting to raw pixels:
```rust
pub fn decode_image(bytes: &[u8]) -> Result<DynamicImage, ImgFprintError> {
    if bytes.is_empty() {
        return Err(ImgFprintError::invalid_image("empty input"));
    }
    // Read ONLY the header โ€” don't decompress yet
    let reader = image::io::Reader::new(std::io::Cursor::new(bytes))
        .with_guessed_format()
        .map_err(/* ... */)?;
    
    // Validate dimensions before decompression
    if let Some((w, h)) = reader.into_dimensions().ok() {
        const MAX_DIM: u32 = 8192;
        if w > MAX_DIM || h > MAX_DIM {
            return Err(ImgFprintError::invalid_image(
                format!("image dimensions {}x{} exceed maximum {}x{}", w, h, MAX_DIM, MAX_DIM)
            ));
        }
    }
    // Now safe to fully decode
    // ...
}
```

---

### 4. `unsafe set_len` Without Initialization (`src/imgproc/preprocess.rs`)

Two occurrences of this pattern:
```rust
unsafe {
    self.dst_buffer.clear();
    let target_len = (NORMALIZED_SIZE * NORMALIZED_SIZE * 3) as usize;
    self.dst_buffer.reserve(target_len);
    self.dst_buffer.set_len(target_len);  // โ† uninitialized memory
}
```

While `u8` is valid for any bit pattern, the `fast_image_resize` crate may not fully overwrite every byte (e.g., if resize fails partway). Any failure after `set_len` but before full write would pass uninitialized data into the grayscale conversion. The `#[allow(clippy::uninit_vec)]` suppression is a warning sign.

**Fix:** Use `resize` instead:
```rust
let target_len = (NORMALIZED_SIZE * NORMALIZED_SIZE * 3) as usize;
self.dst_buffer.clear();
self.dst_buffer.resize(target_len, 0u8);
```
The resize to 256ร—256 is the hot path โ€” this costs ~65KB of zeroing, which modern memset handles near bandwidth speed and is dominated by the actual resize cost anyway.

---

## ๐ŸŸ  High Priority

### 5. `apply_orientation` Likely a No-Op โ€” EXIF Is Lost After Decoding

`apply_orientation` is called with a `&DynamicImage`, but the `image` crate's `DynamicImage` does **not** carry EXIF metadata. EXIF is discarded during `image::load_from_memory`. The orientation function can never receive non-identity orientation data.

**Evidence:** The only test is `test_apply_orientation_no_transform` โ€” it only tests that images without transforms pass through unchanged.

**Fix:** Use `kamadak-exif` or `exif` crate to read orientation from the raw bytes *before* calling `decode_image`, then apply the transform:
```rust
fn decode_with_orientation(bytes: &[u8]) -> Result<DynamicImage, ImgFprintError> {
    let image = decode_image(bytes)?;
    let orientation = read_exif_orientation(bytes).unwrap_or(1);
    Ok(apply_orientation_transform(image, orientation))
}
```

---

### 6. `EmbeddingProvider` Trait Is Synchronous โ€” Unusable in Async Contexts (`src/embed/`)

```rust
pub trait EmbeddingProvider {
    fn embed(&self, image: &[u8]) -> Result<Embedding, ImgFprintError>;
}
```

Any real-world embedding provider calls an HTTP API (OpenAI, HuggingFace, etc.), which is async. Users in tokio runtimes must wrap this in `spawn_blocking`, which is error-prone and undocumented.

**Fix:** Either add an async variant or provide a blanket impl bridge:
```rust
// Option A: async-first trait (requires async-trait crate)
#[async_trait::async_trait]
pub trait AsyncEmbeddingProvider: Send + Sync {
    async fn embed(&self, image: &[u8]) -> Result<Embedding, ImgFprintError>;
}

// Option B: document the spawn_blocking pattern prominently
// and add a helper wrapper
pub async fn embed_async<P: EmbeddingProvider + Send + Sync + 'static>(
    provider: Arc<P>,
    image: Vec<u8>,
) -> Result<Embedding, ImgFprintError> {
    tokio::task::spawn_blocking(move || provider.embed(&image))
        .await
        .map_err(|e| ImgFprintError::ProviderError(e.to_string()))?
}
```

---

### 7. `chunk_size = 0` Causes Panic in Batch Processing

`fingerprint_batch_chunked(&images, 0, callback)` will panic or infinite loop depending on how the chunks iterator handles size-zero chunks. No validation is documented or implemented.

**Fix:**
```rust
pub fn fingerprint_batch_chunked<S, F>(
    images: &[(S, Vec<u8>)],
    chunk_size: usize,
    callback: F,
) where /* ... */ {
    assert!(chunk_size > 0, "chunk_size must be positive");
    // or return early: if chunk_size == 0 { return; }
    for chunk in images.chunks(chunk_size.max(1)) {
        // ...
    }
}
```

---

### 8. Inconsistent Similarity Algorithms for Same-Named Methods

There are two completely different similarity implementations under confusingly similar names:

| Call site | Algorithm |
|-----------|-----------|
| `ImageFingerprinter::compare(&fp1, &fp2)` | 40% global hash + 60% block hashes |
| `fp1.compare(&fp2)` on `MultiHashFingerprint` | 10% AHash + 60% PHash + 30% DHash (global only, no blocks) |
| `fp.is_similar(&other, t)` on `ImageFingerprint` | Global hash only |
| `fp.is_similar(&other, t)` on `MultiHashFingerprint` | Weighted multi-algo |

`MultiHashFingerprint::compare` ignores block hashes entirely, while `compute_similarity` for `ImageFingerprint` uses them for crop resistance. This means the "superior" multi-algo path is actually *worse* for crop resistance than the single-algo path.

**Fix:** `MultiHashFingerprint::compare` should incorporate block-level similarity from each algorithm:
```rust
// Example: per-algorithm block similarity included
let ahash_sim = compute_similarity(&self.ahash, &other.ahash).score;
let phash_sim = compute_similarity(&self.phash, &other.phash).score;
let dhash_sim = compute_similarity(&self.dhash, &other.dhash).score;
let weighted = ahash_sim * AHASH_WEIGHT + phash_sim * PHASH_WEIGHT + dhash_sim * DHASH_WEIGHT;
```

---

### 9. `dct2_32` Uses `unwrap()` in Core Hot Path

```rust
fft.process(&mut buffer, &mut complex_buffer).unwrap();
```

While this path shouldn't fail for fixed-size inputs, it violates the library's own promise of "never panics on malformed input." If the `realfft` crate changes its error conditions in a patch release, this becomes a silent panic in production.

**Fix:**
```rust
fft.process(&mut buffer, &mut complex_buffer)
    .map_err(|e| ImgFprintError::processing_error(format!("DCT FFT failed: {}", e)))?;
```
And propagate the error up from `compute_phash`.

---

## ๐ŸŸก Medium Priority

### 10. POPCOUNT_TABLE May Be Slower Than Intrinsic `count_ones()` on Modern Hardware

```rust
pub fn hamming_distance(a: u64, b: u64) -> u32 {
    let xor = a ^ b;
    POPCOUNT_TABLE[xor as usize & 0xFF] as u32 + /* 7 more lookups */
}
```

On x86-64 with `POPCNT` instruction (which rustc emits for `count_ones()` with appropriate target features), a single `popcntq` instruction handles 64 bits in one cycle. The 8-table-lookup approach requires 8 memory accesses, 7 shifts, 7 additions, and 8 AND operations โ€” likely 3-5ร— slower on CPUs with `POPCNT`.

**Fix:** Benchmark both and use the winner. The fix is one line:
```rust
#[inline(always)]
pub fn hamming_distance(a: u64, b: u64) -> u32 {
    (a ^ b).count_ones()
}
```
With `RUSTFLAGS="-C target-cpu=native"` or a `#[cfg(target_feature = "popcnt")]` guard.

---

### 11. `find_duplicates.rs` Example Uses `HashMap` on Hash Strings โ€” O(n) String Alloc Per Image

```rust
fingerprints
    .entry(format!("{:016x}", hash))  // allocates a String for every image
    .or_default()
    .push((filename, hash));
```

For large image sets, this allocates a 16-character heap string per image just to use as a map key.

**Fix:** Key by `u64` directly:
```rust
let mut fingerprints: HashMap<u64, Vec<String>> = HashMap::new();
fingerprints.entry(hash).or_default().push(filename);
```

---

### 12. No `#![deny(missing_docs)]` for Published Crate

Several `pub` items lack documentation (e.g., `Similarity::perfect()` has docs but internal helpers don't). For a `docs.rs`-published library, missing docs are a quality signal.

**Fix:** Add to `src/lib.rs`:
```rust
#![deny(missing_docs)]
#![deny(clippy::all)]
#![deny(clippy::pedantic)]
```

---

### 13. `serde` Feature Name Shadows Crate Name (Non-Idiomatic)

```toml
[features]
serde = []

[dependencies]
serde = { version = "1.0", features = ["derive"] }
```

This is valid but means `serde` the crate is always compiled in (it appears under `[dependencies]`, not `[optional]`). The feature gate only controls whether `derive` macros are used for the library's own types, not whether `serde` itself is compiled.

**Fix:**
```toml
[dependencies]
serde = { version = "1.0", features = ["derive"], optional = true }

[features]
serde = ["dep:serde"]
```

---

### 14. `Embedding` Has No Source/Model Tagging

```rust
pub struct Embedding { vector: Vec<f32> }
```

If a user accidentally compares embeddings from a 512-dim CLIP model against ones from a 768-dim model, they get `EmbeddingDimensionMismatch`. But if two models happen to output the same dimension, the comparison is semantically meaningless with no error. 

**Fix:** Add an optional model identifier:
```rust
pub struct Embedding {
    vector: Vec<f32>,
    model_id: Option<String>, // e.g., "clip-vit-base-patch32"
}
// semantic_similarity warns or errors if model_ids differ
```

---

### 15. `Similarity.perceptual_distance` Is Misleading in Multi-Hash Mode

In `MultiHashFingerprint::compare()`:
```rust
let avg_distance = ((ahash_dist as f32 * AHASH_WEIGHT)
    + (phash_dist as f32 * PHASH_WEIGHT)
    + (dhash_dist as f32 * DHASH_WEIGHT)) as u32;
```

This weighted average of Hamming distances from three different algorithms is not a "perceptual distance" in any standard meaning. A PHash distance of 8 and a DHash distance of 8 are not equivalent. The field documents "Hamming distance between global perceptual hashes (0-64)" which is now incorrect.

**Fix:** Either expose individual distances or rename/document the field as "weighted composite distance."

---

### 16. `FingerprinterContext` Is Not `Clone` โ€” Forces Re-Initialization Overhead

For use cases like server startup where you want N worker threads, each initialized with a warm context, users must call `FingerprinterContext::new()` per thread. No `Clone` or `Default` documentation hints at the thread-per-context pattern.

**Fix:** Add `Clone` where possible and document thread-per-context:
```rust
// In fingerprinter.rs docs
/// For multi-threaded use, create one context per thread or use
/// [`ImageFingerprinter`]'s static methods which maintain thread-local contexts.
```

---

## ๐ŸŸข Polish & Low Priority

### 17. Cargo.toml: `bincode = "1.3.3"` is Obsolete

`bincode` v2 has a completely different API with explicit configuration. The dev-dependency should either be upgraded to v2 or pinned with an explanatory comment:
```toml
bincode = "1.3.3"  # v2 has breaking API changes; pin until upgrade
```

### 18. CHANGELOG.md Shows `[0.3.2] - Unreleased` but `Cargo.toml` Says `0.3.1`

These should be kept in sync. Use a pre-release tag:
```toml
version = "0.3.2-dev"
```
or automate with `cargo release`.

### 19. `compute_block_similarity` Threshold Is a Magic Number Without Validation

```rust
const BLOCK_SIMILARITY_THRESHOLD: u32 = 32;  // not defined โ€” hardcoded inline
if dist > 32 { /* skip block */ }
```

This threshold was chosen empirically but is not exposed, not configurable, and not tested in isolation. A test that verifies crop resistance (e.g., confirm a 50%-cropped image scores > 0.5) would document this behavior.

### 20. CI Gap: No Test for MSRV (Rust 1.70)

`rust-version = "1.70"` in `Cargo.toml`, but there's likely no CI step that runs `rustup override set 1.70.0 && cargo test`. New dependencies (especially `realfft`, `blake3`) may have raised their MSRV without notice.

### 21. `#[cold]` / `#[inline(never)]` on Error Constructors Is Good โ€” But Incomplete

```rust
#[cold]
#[inline(never)]
pub fn decode_error(msg: impl Into<String>) -> Self { ... }
```

This is a nice touch for keeping error paths out of hot code. However, `EmbeddingDimensionMismatch` has no constructor helper (it's constructed inline with struct syntax), and `ProviderError` + `InvalidEmbedding` have no `#[cold]` constructors. Be consistent.

### 22. `tracing` Feature Uses `#[cfg_attr]` Everywhere โ€” Noisy

The pattern `#[cfg_attr(feature = "tracing", instrument(...))]` appears many times. Consider wrapping in a macro or using a conditional import trick to reduce boilerplate:
```rust
// tracing_shim.rs
#[cfg(feature = "tracing")]
pub use tracing::instrument;
#[cfg(not(feature = "tracing"))]
macro_rules! instrument {
    ($($tt:tt)*) => {};  // no-op
}
```

### 23. `extract_global_region` and `extract_blocks` Should Be Zero-Copy Slices

These functions likely copy data out of the normalized `GrayImage`. For a zero-allocation hot path, consider working with raw slices into the GrayImage buffer directly, avoiding any copy before hashing.

---

## Summary Table

| # | Severity | Category | File | Issue |
|---|----------|----------|------|-------|
| 1 | ๐Ÿ”ด Critical | Correctness | `hash/phash.rs` | DCT-II implementation is mathematically wrong (missing twiddle factors) |
| 2 | ๐Ÿ”ด Critical | Performance/Memory | `core/fingerprinter.rs` | Duplicate TLS contexts double per-thread memory |
| 3 | ๐Ÿ”ด Critical | Security | `imgproc/decode.rs` | Missing pre-decompression dimension check โ€” OOM/DoS risk |
| 4 | ๐Ÿ”ด Critical | Safety | `imgproc/preprocess.rs` | `unsafe set_len` on uninitialized buffer |
| 5 | ๐ŸŸ  High | Correctness | `imgproc/preprocess.rs` | `apply_orientation` is a no-op โ€” EXIF is lost before it's called |
| 6 | ๐ŸŸ  High | API Design | `embed/` | Sync-only embedding trait unusable in async runtimes |
| 7 | ๐ŸŸ  High | Safety | batch API | `chunk_size = 0` can panic |
| 8 | ๐ŸŸ  High | Correctness | `core/fingerprint.rs` | Multi-algo path skips block hashes โ€” worse crop resistance than single-algo |
| 9 | ๐ŸŸ  High | Safety | `hash/phash.rs` | `unwrap()` in hot path breaks "no panic" guarantee |
| 10 | ๐ŸŸก Medium | Performance | `core/similarity.rs` | POPCOUNT table may be slower than `count_ones()` on `POPCNT`-capable CPUs |
| 11 | ๐ŸŸก Medium | Performance | `examples/find_duplicates.rs` | Unnecessary String allocation per image in example |
| 12 | ๐ŸŸก Medium | API Quality | `src/lib.rs` | Missing `#![deny(missing_docs)]` |
| 13 | ๐ŸŸก Medium | Build | `Cargo.toml` | `serde` feature doesn't gate the `serde` crate as optional |
| 14 | ๐ŸŸก Medium | API Design | `embed/` | `Embedding` has no model ID โ€” silent semantic mismatch possible |
| 15 | ๐ŸŸก Medium | API Design | `core/fingerprint.rs` | `perceptual_distance` is misleading in multi-hash context |
| 16 | ๐ŸŸก Medium | Ergonomics | `core/fingerprinter.rs` | `FingerprinterContext` not `Clone`, no multi-thread guidance |
| 17 | ๐ŸŸข Low | Build | `Cargo.toml` | `bincode 1.x` outdated dev-dependency |
| 18 | ๐ŸŸข Low | Release | `CHANGELOG.md` | Version mismatch between changelog and Cargo.toml |
| 19 | ๐ŸŸข Low | Test | `core/similarity.rs` | Magic number threshold not validated by dedicated test |
| 20 | ๐ŸŸข Low | CI | CI config | No MSRV check for `rust-version = "1.70"` |
| 21 | ๐ŸŸข Low | API Quality | `error.rs` | Inconsistent `#[cold]` coverage on error constructors |
| 22 | ๐ŸŸข Low | DX | multiple files | `#[cfg_attr(feature = "tracing", ...)]` noise โ€” consider a macro wrapper |
| 23 | ๐ŸŸข Low | Performance | `imgproc/preprocess.rs` | `extract_global_region`/`extract_blocks` may copy instead of slice |

---

## Prioritized Action Plan

**Immediately (before next release):**
1. Fix `chunk_size = 0` panic guard (#7) โ€” one-liner, no breaking change
2. Add pre-decompression dimension check in `decode_image` (#3) โ€” safety critical
3. Replace `unsafe set_len` with `resize` (#4) โ€” safe equivalent, same perf
4. Fix duplicate TLS contexts (#2) โ€” memory regression, easy fix

**Next minor version (0.3.x):**
5. Fix `apply_orientation` EXIF pipeline (#5)
6. Make `serde` crate optional (#13)
7. Add `chunk_size` validation docs and guard (#7)
8. Benchmark and decide POPCOUNT vs `count_ones()` (#10)
9. Fix `unwrap()` in `dct2_32` (#9)
10. Add `#![deny(missing_docs)]` and fill gaps (#12)

**Next major version (0.4.0 โ€” breaking):**
11. Fix DCT-II twiddle factors (#1) โ€” changes all PHash values
12. Incorporate block hashes in `MultiHashFingerprint::compare` (#8) โ€” changes scores
13. Add async `EmbeddingProvider` trait (#6)
14. Clarify `perceptual_distance` semantics (#15)