usearch 0.20.0 → 0.22.0
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.
- package/.github/workflows/prerelease.yml +46 -0
- package/.github/workflows/release.yml +28 -0
- package/.github/workflows/update_version.sh +2 -1
- package/CMakeLists.txt +4 -0
- package/Cargo.toml +1 -1
- package/README.md +93 -585
- package/c/lib.cpp +19 -15
- package/c/usearch.h +26 -23
- package/cmake/emscripten.cmake +8 -0
- package/conanfile.py +1 -1
- package/cpp/CMakeLists.txt +6 -2
- package/cpp/README.md +88 -0
- package/cpp/bench.cpp +72 -44
- package/cpp/test.cpp +13 -7
- package/docs/benchmarks.md +14 -12
- package/docs/golang/index.rst +6 -0
- package/docs/index.rst +5 -2
- package/docs/python/reference.rst +9 -2
- package/docs/rust/index.rst +6 -0
- package/docs/swift/index.rst +6 -0
- package/golang/README.md +28 -0
- package/include/usearch/index.hpp +668 -123
- package/include/usearch/index_punned_dense.hpp +305 -93
- package/include/usearch/index_punned_helpers.hpp +28 -3
- package/java/README.md +22 -0
- package/javascript/README.md +27 -0
- package/javascript/lib.cpp +1 -1
- package/package.json +1 -1
- package/python/README.md +283 -0
- package/python/lib.cpp +118 -16
- package/python/scripts/join.py +155 -0
- package/python/scripts/test.py +45 -241
- package/python/scripts/test_jit.py +244 -0
- package/python/usearch/eval.py +11 -3
- package/python/usearch/index.py +207 -26
- package/rust/README.md +67 -0
- package/swift/README.md +20 -0
- package/wasm/CMakeLists.txt +15 -0
- package/wasm/lib.cpp +5 -0
- package/wasmer.toml +8 -0
- package/python/usearch/join.py +0 -98
package/python/usearch/index.py
CHANGED
|
@@ -5,12 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
# Python tooling, linters, and static analyzers. It also embeds JIT
|
|
6
6
|
# into the primary `Index` class, connecting USearch with Numba.
|
|
7
7
|
import os
|
|
8
|
+
import math
|
|
8
9
|
from typing import Optional, Union, NamedTuple, List, Iterable
|
|
9
10
|
|
|
10
11
|
import numpy as np
|
|
11
12
|
from tqdm import tqdm
|
|
12
13
|
|
|
13
|
-
from usearch.compiled import Index as _CompiledIndex, IndexMetadata
|
|
14
|
+
from usearch.compiled import Index as _CompiledIndex, IndexMetadata, IndexStats
|
|
14
15
|
from usearch.compiled import SparseIndex as _CompiledSetsIndex
|
|
15
16
|
|
|
16
17
|
from usearch.compiled import MetricKind, ScalarKind, MetricSignature
|
|
@@ -141,6 +142,13 @@ class Matches(NamedTuple):
|
|
|
141
142
|
best_matches = self.labels if not self.is_multiple else self.labels[:, 0]
|
|
142
143
|
return np.sum(best_matches == expected) / len(expected)
|
|
143
144
|
|
|
145
|
+
def recall(self, expected: np.ndarray) -> float:
|
|
146
|
+
assert len(expected) == self.batch_size
|
|
147
|
+
recall = 0
|
|
148
|
+
for i in range(self.batch_size):
|
|
149
|
+
recall += expected[i] in self.labels[i]
|
|
150
|
+
return recall / len(expected)
|
|
151
|
+
|
|
144
152
|
def __repr__(self) -> str:
|
|
145
153
|
return (
|
|
146
154
|
f"usearch.Matches({self.total_matches})"
|
|
@@ -311,15 +319,18 @@ class Index:
|
|
|
311
319
|
pass `copy=False`, if you can guarantee the lifetime of the
|
|
312
320
|
primary vectors store during the process of construction.
|
|
313
321
|
|
|
314
|
-
:param labels: Unique identifier for passed vectors, optional
|
|
322
|
+
:param labels: Unique identifier(s) for passed vectors, optional
|
|
315
323
|
:type labels: Buffer
|
|
316
|
-
:param vectors:
|
|
324
|
+
:param vectors: Vector or a row-major matrix
|
|
317
325
|
:type vectors: Buffer
|
|
318
326
|
:param copy: Should the index store a copy of vectors, defaults to True
|
|
319
327
|
:type copy: bool, optional
|
|
320
328
|
:param threads: Optimal number of cores to use, defaults to 0
|
|
321
329
|
:type threads: int, optional
|
|
322
|
-
|
|
330
|
+
:param log: Whether to print the progress bar, default to False
|
|
331
|
+
:type log: Union[str, bool], optional
|
|
332
|
+
:param batch_size: Number of vectors to process at once, defaults to 0
|
|
333
|
+
:type batch_size: int, optional
|
|
323
334
|
:return: Inserted label or labels
|
|
324
335
|
:type: Union[int, np.ndarray]
|
|
325
336
|
"""
|
|
@@ -347,6 +358,10 @@ class Index:
|
|
|
347
358
|
count_labels = len(labels) if isinstance(labels, Iterable) else 1
|
|
348
359
|
assert count_labels == count_vectors
|
|
349
360
|
|
|
361
|
+
# If logging is requested, and batch size is undefined, set it to grow 1% at a time:
|
|
362
|
+
if log and batch_size == 0:
|
|
363
|
+
batch_size = int(math.ceil(count_vectors / 100))
|
|
364
|
+
|
|
350
365
|
# Split into batches and log progress, if needed
|
|
351
366
|
if batch_size:
|
|
352
367
|
labels = [
|
|
@@ -387,27 +402,99 @@ class Index:
|
|
|
387
402
|
log: Union[str, bool] = False,
|
|
388
403
|
batch_size: int = 0,
|
|
389
404
|
) -> Matches:
|
|
390
|
-
"""
|
|
405
|
+
"""
|
|
406
|
+
Performs approximate nearest neighbors search for one or more queries.
|
|
391
407
|
|
|
392
408
|
:param vectors: Query vector or vectors.
|
|
393
409
|
:type vectors: Buffer
|
|
394
410
|
:param k: Upper limit on the number of matches to find, defaults to 10
|
|
395
411
|
:type k: int, optional
|
|
396
|
-
|
|
397
412
|
:param threads: Optimal number of cores to use, defaults to 0
|
|
398
413
|
:type threads: int, optional
|
|
399
414
|
:param exact: Perform exhaustive linear-time exact search, defaults to False
|
|
400
415
|
:type exact: bool, optional
|
|
416
|
+
:param log: Whether to print the progress bar, default to False
|
|
417
|
+
:type log: Union[str, bool], optional
|
|
418
|
+
:param batch_size: Number of vectors to process at once, defaults to 0
|
|
419
|
+
:type batch_size: int, optional
|
|
401
420
|
:return: Approximate matches for one or more queries
|
|
402
421
|
:rtype: Matches
|
|
403
422
|
"""
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
423
|
+
|
|
424
|
+
assert isinstance(vectors, np.ndarray), "Expects a NumPy array"
|
|
425
|
+
assert vectors.ndim == 1 or vectors.ndim == 2, "Expects a matrix or vector"
|
|
426
|
+
count_vectors = vectors.shape[0] if vectors.ndim == 2 else 1
|
|
427
|
+
|
|
428
|
+
if log and batch_size == 0:
|
|
429
|
+
batch_size = int(math.ceil(count_vectors / 100))
|
|
430
|
+
|
|
431
|
+
if batch_size:
|
|
432
|
+
tasks = [
|
|
433
|
+
vectors[start_row : start_row + batch_size, :]
|
|
434
|
+
for start_row in range(0, count_vectors, batch_size)
|
|
435
|
+
]
|
|
436
|
+
tasks_matches = []
|
|
437
|
+
name = log if isinstance(log, str) else "Search"
|
|
438
|
+
pbar = tqdm(
|
|
439
|
+
tasks,
|
|
440
|
+
desc=name,
|
|
441
|
+
total=count_vectors,
|
|
442
|
+
unit="Vector",
|
|
443
|
+
disable=log is False,
|
|
444
|
+
)
|
|
445
|
+
for vectors in tasks:
|
|
446
|
+
tuple_ = self._compiled.search(
|
|
447
|
+
vectors,
|
|
448
|
+
k,
|
|
449
|
+
exact=exact,
|
|
450
|
+
threads=threads,
|
|
451
|
+
)
|
|
452
|
+
tasks_matches.append(Matches(*tuple_))
|
|
453
|
+
pbar.update(vectors.shape[0])
|
|
454
|
+
|
|
455
|
+
pbar.close()
|
|
456
|
+
return Matches(
|
|
457
|
+
labels=np.vstack([m.labels for m in tasks_matches]),
|
|
458
|
+
distances=np.vstack([m.distances for m in tasks_matches]),
|
|
459
|
+
counts=np.concatenate([m.counts for m in tasks_matches], axis=None),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
else:
|
|
463
|
+
tuple_ = self._compiled.search(
|
|
464
|
+
vectors,
|
|
465
|
+
k,
|
|
466
|
+
exact=exact,
|
|
467
|
+
threads=threads,
|
|
468
|
+
)
|
|
469
|
+
return Matches(*tuple_)
|
|
470
|
+
|
|
471
|
+
def remove(
|
|
472
|
+
self,
|
|
473
|
+
labels: Union[int, Iterable[int]],
|
|
474
|
+
*,
|
|
475
|
+
compact: bool = False,
|
|
476
|
+
threads: int = 0,
|
|
477
|
+
) -> Union[bool, int]:
|
|
478
|
+
"""Removes one or move vectors from the index.
|
|
479
|
+
|
|
480
|
+
When working with extremely large indexes, you may want to
|
|
481
|
+
mark some entries deleted, instead of rebuilding a filtered index.
|
|
482
|
+
In other cases, rebuilding - is the recommended approach.
|
|
483
|
+
|
|
484
|
+
:param labels: Unique identifier for passed vectors, optional
|
|
485
|
+
:type labels: Buffer
|
|
486
|
+
:param compact: Removes links to removed nodes (expensive), defaults to False
|
|
487
|
+
:type compact: bool, optional
|
|
488
|
+
:param threads: Optimal number of cores to use, defaults to 0
|
|
489
|
+
:type threads: int, optional
|
|
490
|
+
:return: Number of removed entries
|
|
491
|
+
:type: Union[bool, int]
|
|
492
|
+
"""
|
|
493
|
+
return self._compiled.remove(labels, compact=compact, threads=threads)
|
|
494
|
+
|
|
495
|
+
def rename(self, label_from: int, label_to: int) -> bool:
|
|
496
|
+
"""Relabel existing entry"""
|
|
497
|
+
return self._compiled.rename(label_from, label_to)
|
|
411
498
|
|
|
412
499
|
@property
|
|
413
500
|
def specs(self) -> dict:
|
|
@@ -429,6 +516,19 @@ class Index:
|
|
|
429
516
|
def __len__(self) -> int:
|
|
430
517
|
return self._compiled.__len__()
|
|
431
518
|
|
|
519
|
+
def __delitem__(self, label: int) -> bool:
|
|
520
|
+
raise self.remove(label)
|
|
521
|
+
|
|
522
|
+
def __contains__(self, label: int) -> bool:
|
|
523
|
+
return self._compiled.__contains__(label)
|
|
524
|
+
|
|
525
|
+
def __getitem__(self, label: int) -> np.ndarray:
|
|
526
|
+
dtype = self.dtype
|
|
527
|
+
get_dtype = _to_numpy_compatible_dtype(dtype)
|
|
528
|
+
vector = self._compiled.__getitem__(label, get_dtype)
|
|
529
|
+
view_dtype = _to_numpy_dtype(dtype)
|
|
530
|
+
return None if vector is None else vector.view(view_dtype)
|
|
531
|
+
|
|
432
532
|
@property
|
|
433
533
|
def jit(self) -> bool:
|
|
434
534
|
return self._metric_jit is not None
|
|
@@ -442,8 +542,8 @@ class Index:
|
|
|
442
542
|
return self._compiled.ndim
|
|
443
543
|
|
|
444
544
|
@property
|
|
445
|
-
def metric(self) -> MetricKind:
|
|
446
|
-
return self._metric_kind
|
|
545
|
+
def metric(self) -> Union[MetricKind, CompiledMetric]:
|
|
546
|
+
return self._metric_jit if self._metric_jit else self._metric_kind
|
|
447
547
|
|
|
448
548
|
@property
|
|
449
549
|
def dtype(self) -> ScalarKind:
|
|
@@ -501,13 +601,64 @@ class Index:
|
|
|
501
601
|
def close(self):
|
|
502
602
|
self._compiled.close()
|
|
503
603
|
|
|
604
|
+
def copy(self) -> Index:
|
|
605
|
+
result = Index(
|
|
606
|
+
ndim=self.ndim,
|
|
607
|
+
metric=self.metric,
|
|
608
|
+
dtype=self.dtype,
|
|
609
|
+
connectivity=self.connectivity,
|
|
610
|
+
expansion_add=self.expansion_add,
|
|
611
|
+
expansion_search=self.expansion_search,
|
|
612
|
+
path=self.path,
|
|
613
|
+
)
|
|
614
|
+
result._compiled = self._compiled.copy()
|
|
615
|
+
return result
|
|
616
|
+
|
|
617
|
+
def join(self, other: Index, max_proposals: int = 0, exact: bool = False) -> dict:
|
|
618
|
+
"""Performs "Semantic Join" or pairwise matching between `self` & `other` index.
|
|
619
|
+
Is different from `search`, as no collisions are allowed in resulting pairs.
|
|
620
|
+
Uses the concept of "Stable Marriages" from Combinatorics, famous for the 2012
|
|
621
|
+
Nobel Prize in Economics.
|
|
622
|
+
|
|
623
|
+
:param other: Another index.
|
|
624
|
+
:type other: Index
|
|
625
|
+
:param max_proposals: Limit on candidates evaluated per vector, defaults to 0
|
|
626
|
+
:type max_proposals: int, optional
|
|
627
|
+
:param exact: Controls if underlying `search` should be exact, defaults to False
|
|
628
|
+
:type exact: bool, optional
|
|
629
|
+
:return: Mapping from labels of `self` to labels of `other`
|
|
630
|
+
:rtype: dict
|
|
631
|
+
"""
|
|
632
|
+
return self._compiled.join(
|
|
633
|
+
other=other._compiled,
|
|
634
|
+
max_proposals=max_proposals,
|
|
635
|
+
exact=exact,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def get_labels(self, offset: int = 0, limit: int = 0) -> np.ndarray:
|
|
639
|
+
if limit == 0:
|
|
640
|
+
limit = 2**63 - 1
|
|
641
|
+
return self._compiled.get_labels(offset, limit)
|
|
642
|
+
|
|
504
643
|
@property
|
|
505
644
|
def labels(self) -> np.ndarray:
|
|
645
|
+
"""Retrieves the labels of all vectors present in `self`
|
|
646
|
+
|
|
647
|
+
:return: Array of labels
|
|
648
|
+
:rtype: np.ndarray
|
|
649
|
+
"""
|
|
506
650
|
return self._compiled.labels
|
|
507
651
|
|
|
508
652
|
def get_vectors(
|
|
509
|
-
self,
|
|
653
|
+
self,
|
|
654
|
+
labels: np.ndarray,
|
|
655
|
+
dtype: ScalarKind = ScalarKind.F32,
|
|
510
656
|
) -> np.ndarray:
|
|
657
|
+
"""Retrieves vectors associated with given `labels`
|
|
658
|
+
|
|
659
|
+
:return: Matrix of vectors (row-major)
|
|
660
|
+
:rtype: np.ndarray
|
|
661
|
+
"""
|
|
511
662
|
dtype = _normalize_dtype(dtype, self._metric_kind)
|
|
512
663
|
get_dtype = _to_numpy_compatible_dtype(dtype)
|
|
513
664
|
vectors = np.vstack([self._compiled.__getitem__(l, get_dtype) for l in labels])
|
|
@@ -518,15 +669,45 @@ class Index:
|
|
|
518
669
|
def vectors(self) -> np.ndarray:
|
|
519
670
|
return self.get_vectors(self.labels, self.dtype)
|
|
520
671
|
|
|
521
|
-
|
|
522
|
-
|
|
672
|
+
@property
|
|
673
|
+
def max_level(self) -> int:
|
|
674
|
+
return self._compiled.max_level
|
|
523
675
|
|
|
524
|
-
|
|
525
|
-
|
|
676
|
+
@property
|
|
677
|
+
def levels_stats(self) -> IndexStats:
|
|
678
|
+
return self._compiled.levels_stats
|
|
526
679
|
|
|
527
|
-
def
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
return
|
|
680
|
+
def level_stats(self, level: int) -> IndexStats:
|
|
681
|
+
return self._compiled.level_stats(level)
|
|
682
|
+
|
|
683
|
+
def __repr__(self) -> str:
|
|
684
|
+
f = "usearch.Index({} x {}, {}, expansion: {} & {}, {} vectors in {} levels)"
|
|
685
|
+
return f.format(
|
|
686
|
+
self.dtype,
|
|
687
|
+
self.ndim,
|
|
688
|
+
self.metric,
|
|
689
|
+
self.expansion_add,
|
|
690
|
+
self.expansion_search,
|
|
691
|
+
len(self),
|
|
692
|
+
self.max_level + 1,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
def _repr_pretty_(self) -> str:
|
|
696
|
+
level_stats = [
|
|
697
|
+
f"--- {i}. {self.level_stats(i).nodes} nodes" for i in range(self.max_level)
|
|
698
|
+
]
|
|
699
|
+
return "\n".join(
|
|
700
|
+
[
|
|
701
|
+
"usearch.Index",
|
|
702
|
+
"- config" f"-- data type: {self.dtype}",
|
|
703
|
+
f"-- dimensions: {self.ndim}",
|
|
704
|
+
f"-- metric: {self.metric}",
|
|
705
|
+
f"-- expansion on addition:{self.expansion_add} candidates",
|
|
706
|
+
f"-- expansion on search: {self.expansion_search} candidates",
|
|
707
|
+
"- state",
|
|
708
|
+
f"-- size: {self.size} vectors",
|
|
709
|
+
f"-- memory usage: {self.memory_usage} bytes",
|
|
710
|
+
f"-- max level: {self.max_level}",
|
|
711
|
+
*level_stats,
|
|
712
|
+
]
|
|
713
|
+
)
|
package/rust/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# USearch for Rust
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
cargo add usearch
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quickstart
|
|
10
|
+
|
|
11
|
+
```rust
|
|
12
|
+
|
|
13
|
+
let options = IndexOptions {
|
|
14
|
+
dimensions: 5,
|
|
15
|
+
metric: MetricKind::IP,
|
|
16
|
+
quantization: ScalarKind::F16,
|
|
17
|
+
connectivity: 0,
|
|
18
|
+
expansion_add: 0,
|
|
19
|
+
expansion_search: 0
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let index = new_index(&options).unwrap();
|
|
23
|
+
|
|
24
|
+
assert!(index.reserve(10).is_ok());
|
|
25
|
+
assert!(index.capacity() >= 10);
|
|
26
|
+
assert!(index.connectivity() != 0);
|
|
27
|
+
assert_eq!(index.dimensions(), 3);
|
|
28
|
+
assert_eq!(index.size(), 0);
|
|
29
|
+
|
|
30
|
+
let first: [f32; 3] = [0.2, 0.1, 0.2];
|
|
31
|
+
let second: [f32; 3] = [0.2, 0.1, 0.2];
|
|
32
|
+
|
|
33
|
+
assert!(index.add(42, &first).is_ok());
|
|
34
|
+
assert!(index.add(43, &second).is_ok());
|
|
35
|
+
assert_eq!(index.size(), 2);
|
|
36
|
+
|
|
37
|
+
// Read back the tags
|
|
38
|
+
let results = index.search(&first, 10).unwrap();
|
|
39
|
+
assert_eq!(results.count, 2);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Multi-Threading
|
|
43
|
+
|
|
44
|
+
```rust
|
|
45
|
+
assert!(index.add_in_thread(42, &first, 0).is_ok());
|
|
46
|
+
assert!(index.add_in_thread(43, &second, 0).is_ok());
|
|
47
|
+
let results = index.search_in_thread(&first, 10, 0).unwrap();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Being a systems-programming language, Rust has better control over memory management and concurrency but lacks function overloading.
|
|
51
|
+
Aside from the `add` and `search`, USearch Rust binding also provides `add_in_thread` and `search_in_thread`, which let users identify the calling thread to use underlying temporary memory more efficiently.
|
|
52
|
+
|
|
53
|
+
## Serialization
|
|
54
|
+
|
|
55
|
+
```rust
|
|
56
|
+
assert!(index.save("index.usearch").is_ok());
|
|
57
|
+
assert!(index.load("index.usearch").is_ok());
|
|
58
|
+
assert!(index.view("index.usearch").is_ok());
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Metrics
|
|
62
|
+
|
|
63
|
+
```rust
|
|
64
|
+
assert!(new_l2sq(3, &quant, 0, 0, 0).is_ok());
|
|
65
|
+
assert!(new_cos(3, &quant, 0, 0, 0).is_ok());
|
|
66
|
+
assert!(new_haversine(&quant, 0, 0, 0).is_ok());
|
|
67
|
+
```
|
package/swift/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# USearch for Swift
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
```txt
|
|
6
|
+
https://github.com/unum-cloud/usearch
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quickstart
|
|
10
|
+
|
|
11
|
+
```swift
|
|
12
|
+
let index = Index.l2sq(dimensions: 3, connectivity: 8)
|
|
13
|
+
let vectorA: [Float32] = [0.3, 0.5, 1.2]
|
|
14
|
+
let vectorB: [Float32] = [0.4, 0.2, 1.2]
|
|
15
|
+
index.add(label: 42, vector: vectorA[...])
|
|
16
|
+
index.add(label: 43, vector: vectorB[...])
|
|
17
|
+
|
|
18
|
+
let results = index.search(vector: vectorA[...], count: 10)
|
|
19
|
+
assert(results.0[0] == 42)
|
|
20
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
include(emscripten)
|
|
2
|
+
|
|
3
|
+
set(CMAKE_C_COMPILER emcc)
|
|
4
|
+
set(CMAKE_CXX_COMPILER em++)
|
|
5
|
+
|
|
6
|
+
set(USEARCH_PUNNED_INCLUDE_DIRS
|
|
7
|
+
"${CMAKE_CURRENT_SOURCE_DIR}/../c"
|
|
8
|
+
"${CMAKE_CURRENT_SOURCE_DIR}/../include"
|
|
9
|
+
"${CMAKE_CURRENT_SOURCE_DIR}/../fp16/include"
|
|
10
|
+
"${CMAKE_CURRENT_SOURCE_DIR}/../robin-map/include"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
add_executable(index lib.cpp)
|
|
14
|
+
target_include_directories(index PRIVATE ${USEARCH_PUNNED_INCLUDE_DIRS})
|
|
15
|
+
set_target_properties(index PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/wasm)
|
package/wasm/lib.cpp
ADDED
package/wasmer.toml
ADDED
package/python/usearch/join.py
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
from collections import deque
|
|
2
|
-
|
|
3
|
-
import numpy as np
|
|
4
|
-
from numba import njit
|
|
5
|
-
|
|
6
|
-
from index import Index, Matches, Label
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class bidict(dict):
|
|
10
|
-
def __init__(self, *args, **kwargs):
|
|
11
|
-
super(bidict, self).__init__(*args, **kwargs)
|
|
12
|
-
self.inverse = {}
|
|
13
|
-
for key, value in self.items():
|
|
14
|
-
self.inverse.setdefault(value, []).append(key)
|
|
15
|
-
|
|
16
|
-
def __setitem__(self, key, value):
|
|
17
|
-
if key in self:
|
|
18
|
-
self.inverse[self[key]].remove(key)
|
|
19
|
-
super(bidict, self).__setitem__(key, value)
|
|
20
|
-
self.inverse.setdefault(value, []).append(key)
|
|
21
|
-
|
|
22
|
-
def __delitem__(self, key):
|
|
23
|
-
self.inverse.setdefault(self[key], []).remove(key)
|
|
24
|
-
if self[key] in self.inverse and not self.inverse[self[key]]:
|
|
25
|
-
del self.inverse[self[key]]
|
|
26
|
-
super(bidict, self).__delitem__(key)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@njit
|
|
30
|
-
def index_in_array(array: np.ndarray, item: Label) -> int:
|
|
31
|
-
for idx, val in np.ndenumerate(array):
|
|
32
|
-
if val == item:
|
|
33
|
-
return idx
|
|
34
|
-
|
|
35
|
-
return len(array)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def semantic_join(a: Index, b: Index, resolution: int = 10) -> bidict:
|
|
39
|
-
"""Performs a Semantic Join, matching one entry from `a` with
|
|
40
|
-
one entry from `b`, converging towards a Stable Marriage.
|
|
41
|
-
Assuming the collections can be different in size, classical solution
|
|
42
|
-
doesn't provide
|
|
43
|
-
|
|
44
|
-
:param resolution: Approximate matches per member to consider, defaults to 10
|
|
45
|
-
:type resolution: int, optional
|
|
46
|
-
:return: Bidirectional mapping from men to women and `.inverse`
|
|
47
|
-
:rtype: bidict
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
men, women = (a, b) if len(a) > len(b) else (b, a)
|
|
51
|
-
matches = bidict()
|
|
52
|
-
|
|
53
|
-
men_vectors = np.vstack(men[i] for i in range(len(men)))
|
|
54
|
-
women_vectors = np.vstack(women[i] for i in range(len(women)))
|
|
55
|
-
|
|
56
|
-
man_count_proposed = np.zeros(len(men))
|
|
57
|
-
man_to_women_preferences: Matches = men.search(women_vectors, resolution)
|
|
58
|
-
woman_to_men_preferences: Matches = women.search(men_vectors, resolution)
|
|
59
|
-
|
|
60
|
-
# A nice optimization is to prioritize free men by the quality of their
|
|
61
|
-
# best remaining variant. TODO: Replace with `heapq`
|
|
62
|
-
free_men = deque(range(len(men)))
|
|
63
|
-
|
|
64
|
-
idle_cycles: int = 0
|
|
65
|
-
hopeless_men_count = len(men) - len(women)
|
|
66
|
-
while len(free_men) > hopeless_men_count:
|
|
67
|
-
# In the worst case scenario we may need to match more candidates for every
|
|
68
|
-
# remaining man. This, however, may drastically increase the runtime.
|
|
69
|
-
if len(free_men) == idle_cycles:
|
|
70
|
-
break
|
|
71
|
-
|
|
72
|
-
man: Label = free_men.popleft()
|
|
73
|
-
count_proposals = man_count_proposed[man]
|
|
74
|
-
if count_proposals == man_to_women_preferences.counts[man]:
|
|
75
|
-
free_men.append(man)
|
|
76
|
-
idle_cycles += 1
|
|
77
|
-
continue
|
|
78
|
-
|
|
79
|
-
woman: Label = man_to_women_preferences.labels[man, count_proposals]
|
|
80
|
-
man_count_proposed[man] += 1
|
|
81
|
-
|
|
82
|
-
if woman not in matches.inverse:
|
|
83
|
-
matches[man] = woman
|
|
84
|
-
|
|
85
|
-
else:
|
|
86
|
-
her_preferences = woman_to_men_preferences.labels[woman, :]
|
|
87
|
-
husband: Label = matches.inverse[woman]
|
|
88
|
-
husband_rank = index_in_array(her_preferences, husband)
|
|
89
|
-
challenger_rank = index_in_array(her_preferences, man)
|
|
90
|
-
if challenger_rank < husband_rank:
|
|
91
|
-
del matches[husband]
|
|
92
|
-
free_men.append(husband)
|
|
93
|
-
matches[man] = woman
|
|
94
|
-
|
|
95
|
-
else:
|
|
96
|
-
free_men.append(man)
|
|
97
|
-
|
|
98
|
-
return matches
|