pyvectorhound 1.4.0__tar.gz → 1.5.0__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 (92) hide show
  1. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/CHANGELOG.md +12 -0
  2. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/Cargo.lock +1 -1
  3. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/Cargo.toml +1 -2
  4. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/PKG-INFO +81 -36
  5. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/README.md +53 -9
  6. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyproject.toml +2 -1
  7. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scripts/setup_shortcuts.sh +0 -0
  8. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/scripts/setup_shortcuts.sh +0 -0
  9. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/scripts/test_dashboards_crossplatform.sh +0 -0
  10. pyvectorhound-1.5.0/src/lib.rs +196 -0
  11. pyvectorhound-1.5.0/src/quantization.rs +300 -0
  12. pyvectorhound-1.4.0/src/lib.rs +0 -91
  13. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/dependabot.yml +0 -0
  16. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/workflows/ci.yml +0 -0
  17. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.gitignore +0 -0
  18. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.pre-commit-config.yaml +0 -0
  19. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/ARCHITECTURE.md +0 -0
  20. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/CODE_OF_CONDUCT.md +0 -0
  21. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/CONTRIBUTING.md +0 -0
  22. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/DASHBOARD_SHORTCUTS.md +0 -0
  23. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/DEPLOYMENT.md +0 -0
  24. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/LICENSE +0 -0
  25. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/Makefile +0 -0
  26. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/OKF_INTEGRATION.md +0 -0
  27. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/OTEL_SETUP_GUIDE.md +0 -0
  28. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/PRODUCTION_DEPLOYMENT.md +0 -0
  29. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/SECURITY.md +0 -0
  30. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/USER_GUIDE.md +0 -0
  31. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/ARCHITECTURE.md +0 -0
  32. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/GUIDE.md +0 -0
  33. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/PRODUCT_VISION.md +0 -0
  34. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/ROADMAP.md +0 -0
  35. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PYHOUND_FINAL_STATUS.md +0 -0
  36. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_COMPETITIVE_ROADMAP.md +0 -0
  37. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_FINAL_PRODUCT.md +0 -0
  38. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_GITHUB_LAUNCH.md +0 -0
  39. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_LAUNCH_CHECKLIST.md +0 -0
  40. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_UNIFICATION_STRATEGY.md +0 -0
  41. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_vs_Observability.md +0 -0
  42. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/examples/benchmarking_and_trends.py +0 -0
  43. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/examples/mcp_pyvectorhound.py +0 -0
  44. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/examples/retrieval_debug.py +0 -0
  45. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/install.sh +0 -0
  46. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/otel_exporter.py +0 -0
  47. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/__init__.py +0 -0
  48. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_mcp_connector.py +0 -0
  49. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_mcp_tools.py +0 -0
  50. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_retry.py +0 -0
  51. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_safe_exec.py +0 -0
  52. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/advanced_analytics.py +0 -0
  53. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/backends.py +0 -0
  54. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/benchmarking.py +0 -0
  55. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/cli.py +0 -0
  56. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/comparison.py +0 -0
  57. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/database.py +0 -0
  58. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/db_adapters.py +0 -0
  59. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/diagnosis.py +0 -0
  60. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/error_messages.py +0 -0
  61. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/errors.py +0 -0
  62. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/hound.py +0 -0
  63. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/langchain_integration.py +0 -0
  64. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/llamaindex_integration.py +0 -0
  65. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/logging_config.py +0 -0
  66. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/okf_diagnostics.py +0 -0
  67. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/otel_integration.py +0 -0
  68. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/py.typed +0 -0
  69. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/recommendations.py +0 -0
  70. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/retrieval_replay.py +0 -0
  71. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/retrieval_tracing.py +0 -0
  72. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scorer.py +0 -0
  73. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scripts/__init__.py +0 -0
  74. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scripts/post_install.py +0 -0
  75. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/server.py +0 -0
  76. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/trend_analysis.py +0 -0
  77. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/validation.py +0 -0
  78. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/web_dashboard.py +0 -0
  79. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound.toml +0 -0
  80. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/rust-toolchain.toml +0 -0
  81. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/src/metrics.rs +0 -0
  82. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/src/retrieval_ranking.rs +0 -0
  83. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_benchmarking.py +0 -0
  84. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_database_adapters.py +0 -0
  85. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_diagnosis.py +0 -0
  86. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_hound.py +0 -0
  87. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_okf_diagnostics.py +0 -0
  88. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_recommendations.py +0 -0
  89. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_retrieval_replay.py +0 -0
  90. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_scorer.py +0 -0
  91. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_trend_analysis.py +0 -0
  92. {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_v05_integrations.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to PyVectorHound are documented in this file.
4
4
 
5
+ ## [1.5.0]
6
+
7
+ ### Added
8
+
9
+ - **int8 scalar quantization** (`src/quantization.rs`) — per-vector min/max
10
+ linear quantization to `i8`, with `quantize`/`dequantize` (and batch
11
+ variants) exposed via PyO3. 4x memory reduction vs `f32` for large local
12
+ vector indices, with measured reconstruction error (0.0039 max on a
13
+ 384-dim `[-1,1]` test vector) within the theoretical 8-bit bound
14
+ (`range/255`). SIMD/GPU acceleration is a separate, larger effort and
15
+ intentionally out of scope here.
16
+
5
17
  ## [1.4.0]
6
18
 
7
19
  ### Added
@@ -218,7 +218,7 @@ dependencies = [
218
218
 
219
219
  [[package]]
220
220
  name = "pyvectorhound"
221
- version = "1.4.0"
221
+ version = "1.5.0"
222
222
  dependencies = [
223
223
  "numpy",
224
224
  "pyo3",
@@ -1,11 +1,10 @@
1
1
  [package]
2
2
  name = "pyvectorhound"
3
- version = "1.4.0"
3
+ version = "1.5.0"
4
4
  edition = "2021"
5
5
  authors = ["Georgi Mammen Mullassery <mullassery@gmail.com>"]
6
6
  license = "Proprietary"
7
7
  description = "Vector search diagnostics engine for RAG/LLM systems"
8
- readme = "README.md"
9
8
 
10
9
  [dependencies]
11
10
  pyo3 = { version = "0.23", features = ["extension-module"] }
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: pyvectorhound
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -18,46 +18,47 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
19
  Classifier: Topic :: Text Processing
20
20
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
21
- Requires-Dist: numpy>=1.21,<3
22
- Requires-Dist: requests>=2.28,<3
23
- Requires-Dist: python-frontmatter>=1.0.0,<1.2.0
24
- Requires-Dist: pydantic>=2.0,<3
21
+ Requires-Dist: numpy >=1.21, <3
22
+ Requires-Dist: requests >=2.28, <3
23
+ Requires-Dist: python-frontmatter >=1.0.0, <1.2.0
24
+ Requires-Dist: pydantic >=2.0, <3
25
+ Requires-Dist: qdrant-client >=1.7, <3 ; extra == 'qdrant'
26
+ Requires-Dist: chromadb >=0.4, <1 ; extra == 'chroma'
27
+ Requires-Dist: pymilvus >=2.3, <3 ; extra == 'milvus'
28
+ Requires-Dist: weaviate-client >=3.24, <4 ; extra == 'weaviate'
29
+ Requires-Dist: psycopg2-binary >=2.9, <3 ; extra == 'postgres'
30
+ Requires-Dist: psycopg2-binary >=2.9, <3 ; extra == 'pgvector'
31
+ Requires-Dist: pytest >=7.0, <9 ; extra == 'dev'
32
+ Requires-Dist: pytest-cov >=4.0, <7 ; extra == 'dev'
33
+ Requires-Dist: mypy >=1.0, <2 ; extra == 'dev'
34
+ Requires-Dist: black >=23.0, <26 ; extra == 'dev'
35
+ Requires-Dist: ruff >=0.1, <1 ; extra == 'dev'
36
+ Requires-Dist: maturin >=1.7, <2 ; extra == 'dev'
25
37
  Requires-Dist: pyvectorhound[qdrant,chroma,milvus,weaviate,postgres,pgvector,dev] ; extra == 'all'
26
- Requires-Dist: chromadb>=0.4,<1 ; extra == 'chroma'
27
- Requires-Dist: pytest>=7.0,<9 ; extra == 'dev'
28
- Requires-Dist: pytest-cov>=4.0,<7 ; extra == 'dev'
29
- Requires-Dist: mypy>=1.0,<2 ; extra == 'dev'
30
- Requires-Dist: black>=23.0,<26 ; extra == 'dev'
31
- Requires-Dist: ruff>=0.1,<1 ; extra == 'dev'
32
- Requires-Dist: maturin>=1.7,<2 ; extra == 'dev'
33
- Requires-Dist: pymilvus>=2.3,<3 ; extra == 'milvus'
34
- Requires-Dist: psycopg2-binary>=2.9,<3 ; extra == 'pgvector'
35
- Requires-Dist: psycopg2-binary>=2.9,<3 ; extra == 'postgres'
36
- Requires-Dist: qdrant-client>=1.7,<3 ; extra == 'qdrant'
37
- Requires-Dist: weaviate-client>=3.24,<4 ; extra == 'weaviate'
38
- Provides-Extra: all
38
+ Provides-Extra: qdrant
39
39
  Provides-Extra: chroma
40
- Provides-Extra: dev
41
40
  Provides-Extra: milvus
42
- Provides-Extra: pgvector
43
- Provides-Extra: postgres
44
- Provides-Extra: qdrant
45
41
  Provides-Extra: weaviate
42
+ Provides-Extra: postgres
43
+ Provides-Extra: pgvector
44
+ Provides-Extra: dev
45
+ Provides-Extra: all
46
46
  License-File: LICENSE
47
47
  Summary: Diagnostic tool for vector search failures in RAG and LLM systems. Evaluate embedding quality, analyze vector search rank correlation, benchmark BM25 vs semantic search, profile reranker performance. Identify root cause of retrieval failures.
48
48
  Keywords: rag,retrieval-augmented-generation,vector-search,embedding,semantic-search,llm,large-language-models,information-retrieval,search-ranking,evaluation-metrics,bm25,reranking,debugging,diagnostics,nlp,natural-language-processing,similarity-search,faiss,elasticsearch,pinecone,milvus,ai-debugging
49
+ Author: Georgi Mammen Mullassery <mullassery@gmail.com>
49
50
  Author-email: Georgi Mammen Mullassery <mullassery@gmail.com>
50
51
  Maintainer-email: Georgi Mammen Mullassery <mullassery@gmail.com>
51
52
  License: Proprietary
52
53
  Requires-Python: >=3.8
53
54
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
54
- Project-URL: Bug Tracker, https://github.com/Mullassery/Pyvectorhound/issues
55
- Project-URL: Changelog, https://github.com/Mullassery/Pyvectorhound/releases
56
- Project-URL: Discussions, https://github.com/Mullassery/Pyvectorhound/discussions
57
- Project-URL: Documentation, https://github.com/Mullassery/Pyvectorhound#readme
58
55
  Project-URL: Homepage, https://github.com/Mullassery/Pyvectorhound
59
56
  Project-URL: Repository, https://github.com/Mullassery/Pyvectorhound
57
+ Project-URL: Documentation, https://github.com/Mullassery/Pyvectorhound#readme
58
+ Project-URL: Bug Tracker, https://github.com/Mullassery/Pyvectorhound/issues
59
+ Project-URL: Changelog, https://github.com/Mullassery/Pyvectorhound/releases
60
60
  Project-URL: Source Code, https://github.com/Mullassery/Pyvectorhound/tree/main
61
+ Project-URL: Discussions, https://github.com/Mullassery/Pyvectorhound/discussions
61
62
 
62
63
  # PyVectorHound
63
64
 
@@ -131,6 +132,50 @@ native `_core` extension but not yet exposed as a Python-callable function —
131
132
  if you need it from Python today, treat it as in-progress internal
132
133
  infrastructure rather than a public API.
133
134
 
135
+ ### New: int8 scalar quantization (Rust core)
136
+
137
+ `src/quantization.rs` adds per-vector int8 scalar quantization for shrinking
138
+ the memory footprint of large local vector indices. Each `f32` vector is
139
+ mapped linearly to the `i8` range (-128..=127) using that vector's own
140
+ min/max, alongside a `(scale, offset)` pair needed to dequantize it back to
141
+ an approximate `f32` vector. This is scalar quantization, not product
142
+ quantization, and it isn't SIMD/GPU accelerated — both are out of scope for
143
+ this feature.
144
+
145
+ **Tradeoff:** storing `i8` instead of `f32` is a **4x memory reduction**
146
+ for the vector data (1 byte/dim vs 4 bytes/dim), at the cost of bounded
147
+ reconstruction error — max absolute error per element is at most
148
+ `(max - min) / 255` for the vector being quantized (half a quantization
149
+ step in practice). For a typical normalized embedding in `[-1, 1]`, that's
150
+ a worst-case error bound of about `0.0078` per dimension; measured max
151
+ error on a random 384-dim `[-1, 1]` vector in the test suite was `~0.0039`.
152
+ Constant vectors (including all-zero) round-trip exactly.
153
+
154
+ Exposed to Python via `pyvectorhound._core`:
155
+
156
+ ```python
157
+ from pyvectorhound import _core
158
+
159
+ # Single vector
160
+ result = _core.py_quantize_vector([0.12, -0.87, 0.5, 0.0])
161
+ # {"values": [i8, ...], "scale": float, "offset": float, "min": float, "max": float}
162
+ restored = _core.py_dequantize_vector(result["values"], result["scale"], result["offset"])
163
+
164
+ # Batch (one scale/offset per vector)
165
+ batch = _core.py_quantize_batch([[0.1, 0.2], [-1.0, 1.0]])
166
+ # {"values": [[i8, ...], ...], "params": [{"scale":..., "offset":..., "min":..., "max":...}, ...]}
167
+ restored_batch = _core.py_dequantize_batch(
168
+ batch["values"],
169
+ [p["scale"] for p in batch["params"]],
170
+ [p["offset"] for p in batch["params"]],
171
+ )
172
+ ```
173
+
174
+ This is currently a low-level building block (quantize/dequantize
175
+ primitives) rather than integrated into `Hound`'s storage/retrieval path —
176
+ if you want quantized on-disk storage for your own index today, call these
177
+ functions directly.
178
+
134
179
  ### Not yet real (known limitations)
135
180
 
136
181
  Being upfront about what's still a stub, rather than leaving it to look
@@ -146,16 +191,16 @@ finished:
146
191
  has a historical data store to compute a real trend from. Use
147
192
  `Hound.track_metric()` + `Hound.get_trend_report()` (backed by the real,
148
193
  tested `TrendAnalyzer`) instead.
149
- - As of v1.3.1, PyPI only carries a macOS arm64 / CPython 3.11 wheel and no
150
- source distribution. On any other interpreter or OS, `pip install
151
- pyvectorhound` does **not** build the current version from source — it
152
- silently falls back to the last release that does have an sdist (currently
153
- **1.3.0**, three releases behind), with no warning that you got an old
154
- version. If you need the current release outside macOS arm64/CPython 3.11,
155
- install straight from the repo instead, which does build the latest source
156
- correctly (needs a Rust toolchain — `maturin`/`pip` handle the build, but
157
- `cargo` must be available):
194
+ - As of v1.4.0, PyPI carries a macOS arm64 / CPython 3.9 wheel plus a source
195
+ distribution (`sdist`). The wheel is still single-platform/single-ABI (no
196
+ `abi3` build yet — see below), but the sdist means `pip install
197
+ pyvectorhound` on any other interpreter or OS now builds the current
198
+ version from source instead of silently falling back to an old release,
199
+ as long as a Rust toolchain is available locally. If the sdist build
200
+ fails for you, install straight from the repo instead:
158
201
  `pip install git+https://github.com/Mullassery/PyVectorHound.git`.
202
+ A proper multi-platform wheel matrix (built in CI, `abi3` so one wheel
203
+ covers multiple CPython versions) is still open work.
159
204
  - GitHub Actions CI (the badge above) is currently red on every job across
160
205
  recent pushes to `main` — not because of failing tests, but because
161
206
  `.github/workflows/ci.yml`'s `dtolnay/rust-toolchain@v1` step is missing
@@ -70,6 +70,50 @@ native `_core` extension but not yet exposed as a Python-callable function —
70
70
  if you need it from Python today, treat it as in-progress internal
71
71
  infrastructure rather than a public API.
72
72
 
73
+ ### New: int8 scalar quantization (Rust core)
74
+
75
+ `src/quantization.rs` adds per-vector int8 scalar quantization for shrinking
76
+ the memory footprint of large local vector indices. Each `f32` vector is
77
+ mapped linearly to the `i8` range (-128..=127) using that vector's own
78
+ min/max, alongside a `(scale, offset)` pair needed to dequantize it back to
79
+ an approximate `f32` vector. This is scalar quantization, not product
80
+ quantization, and it isn't SIMD/GPU accelerated — both are out of scope for
81
+ this feature.
82
+
83
+ **Tradeoff:** storing `i8` instead of `f32` is a **4x memory reduction**
84
+ for the vector data (1 byte/dim vs 4 bytes/dim), at the cost of bounded
85
+ reconstruction error — max absolute error per element is at most
86
+ `(max - min) / 255` for the vector being quantized (half a quantization
87
+ step in practice). For a typical normalized embedding in `[-1, 1]`, that's
88
+ a worst-case error bound of about `0.0078` per dimension; measured max
89
+ error on a random 384-dim `[-1, 1]` vector in the test suite was `~0.0039`.
90
+ Constant vectors (including all-zero) round-trip exactly.
91
+
92
+ Exposed to Python via `pyvectorhound._core`:
93
+
94
+ ```python
95
+ from pyvectorhound import _core
96
+
97
+ # Single vector
98
+ result = _core.py_quantize_vector([0.12, -0.87, 0.5, 0.0])
99
+ # {"values": [i8, ...], "scale": float, "offset": float, "min": float, "max": float}
100
+ restored = _core.py_dequantize_vector(result["values"], result["scale"], result["offset"])
101
+
102
+ # Batch (one scale/offset per vector)
103
+ batch = _core.py_quantize_batch([[0.1, 0.2], [-1.0, 1.0]])
104
+ # {"values": [[i8, ...], ...], "params": [{"scale":..., "offset":..., "min":..., "max":...}, ...]}
105
+ restored_batch = _core.py_dequantize_batch(
106
+ batch["values"],
107
+ [p["scale"] for p in batch["params"]],
108
+ [p["offset"] for p in batch["params"]],
109
+ )
110
+ ```
111
+
112
+ This is currently a low-level building block (quantize/dequantize
113
+ primitives) rather than integrated into `Hound`'s storage/retrieval path —
114
+ if you want quantized on-disk storage for your own index today, call these
115
+ functions directly.
116
+
73
117
  ### Not yet real (known limitations)
74
118
 
75
119
  Being upfront about what's still a stub, rather than leaving it to look
@@ -85,16 +129,16 @@ finished:
85
129
  has a historical data store to compute a real trend from. Use
86
130
  `Hound.track_metric()` + `Hound.get_trend_report()` (backed by the real,
87
131
  tested `TrendAnalyzer`) instead.
88
- - As of v1.3.1, PyPI only carries a macOS arm64 / CPython 3.11 wheel and no
89
- source distribution. On any other interpreter or OS, `pip install
90
- pyvectorhound` does **not** build the current version from source — it
91
- silently falls back to the last release that does have an sdist (currently
92
- **1.3.0**, three releases behind), with no warning that you got an old
93
- version. If you need the current release outside macOS arm64/CPython 3.11,
94
- install straight from the repo instead, which does build the latest source
95
- correctly (needs a Rust toolchain — `maturin`/`pip` handle the build, but
96
- `cargo` must be available):
132
+ - As of v1.4.0, PyPI carries a macOS arm64 / CPython 3.9 wheel plus a source
133
+ distribution (`sdist`). The wheel is still single-platform/single-ABI (no
134
+ `abi3` build yet — see below), but the sdist means `pip install
135
+ pyvectorhound` on any other interpreter or OS now builds the current
136
+ version from source instead of silently falling back to an old release,
137
+ as long as a Rust toolchain is available locally. If the sdist build
138
+ fails for you, install straight from the repo instead:
97
139
  `pip install git+https://github.com/Mullassery/PyVectorHound.git`.
140
+ A proper multi-platform wheel matrix (built in CI, `abi3` so one wheel
141
+ covers multiple CPython versions) is still open work.
98
142
  - GitHub Actions CI (the badge above) is currently red on every job across
99
143
  recent pushes to `main` — not because of failing tests, but because
100
144
  `.github/workflows/ci.yml`'s `dtolnay/rust-toolchain@v1` step is missing
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "pyvectorhound"
7
- version = "1.4.0"
7
+ version = "1.5.0"
8
8
  description = "Diagnostic tool for vector search failures in RAG and LLM systems. Evaluate embedding quality, analyze vector search rank correlation, benchmark BM25 vs semantic search, profile reranker performance. Identify root cause of retrieval failures."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -93,6 +93,7 @@ Discussions = "https://github.com/Mullassery/Pyvectorhound/discussions"
93
93
  [tool.maturin]
94
94
  module-name = "pyvectorhound._core"
95
95
  features = ["pyo3/extension-module"]
96
+ include = ["LICENSE"]
96
97
 
97
98
  [tool.pytest.ini_options]
98
99
  testpaths = ["tests"]
@@ -0,0 +1,196 @@
1
+ // PyHound Core - Rust implementation of retrieval diagnostics
2
+ //
3
+ // High-performance embedding quality metrics, pipeline analysis, and diagnostics.
4
+
5
+ mod metrics;
6
+ mod quantization;
7
+ mod retrieval_ranking;
8
+
9
+ use metrics::{
10
+ compute_coverage, compute_distinctiveness, compute_isotropy, compute_retrieval_metrics,
11
+ detect_drift,
12
+ };
13
+ use pyo3::prelude::*;
14
+ use pyo3::types::{PyDict, PyList};
15
+ use pyo3::Bound;
16
+ use quantization::{dequantize, dequantize_batch, quantize, quantize_batch, QuantizationParams};
17
+
18
+ /// Build a Python dict from quantization params: {"scale", "offset", "min", "max"}.
19
+ fn params_to_dict<'py>(
20
+ py: Python<'py>,
21
+ params: &QuantizationParams,
22
+ ) -> PyResult<Bound<'py, PyDict>> {
23
+ let dict = PyDict::new(py);
24
+ dict.set_item("scale", params.scale)?;
25
+ dict.set_item("offset", params.offset)?;
26
+ dict.set_item("min", params.min)?;
27
+ dict.set_item("max", params.max)?;
28
+ Ok(dict)
29
+ }
30
+
31
+ /// Compute isotropy of embeddings (vector space utilization)
32
+ #[pyfunction]
33
+ fn py_compute_isotropy(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
34
+ Ok(compute_isotropy(&embeddings))
35
+ }
36
+
37
+ /// Compute coverage of embeddings (diversity of semantic space)
38
+ #[pyfunction]
39
+ fn py_compute_coverage(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
40
+ Ok(compute_coverage(&embeddings))
41
+ }
42
+
43
+ /// Compute distinctiveness of embeddings (semantic separation)
44
+ #[pyfunction]
45
+ fn py_compute_distinctiveness(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
46
+ Ok(compute_distinctiveness(&embeddings))
47
+ }
48
+
49
+ /// Detect drift in embedding quality
50
+ #[pyfunction]
51
+ fn py_detect_drift(
52
+ baseline_embeddings: Vec<Vec<f32>>,
53
+ current_embeddings: Vec<Vec<f32>>,
54
+ ) -> PyResult<f32> {
55
+ Ok(detect_drift(&baseline_embeddings, &current_embeddings))
56
+ }
57
+
58
+ /// Compute retrieval metrics (precision, recall, F1, MRR, NDCG)
59
+ #[pyfunction]
60
+ fn py_compute_retrieval_metrics(
61
+ py: Python,
62
+ retrieved: Vec<usize>,
63
+ relevant: Vec<usize>,
64
+ top_k: usize,
65
+ ) -> PyResult<PyObject> {
66
+ let metrics = compute_retrieval_metrics(&retrieved, &relevant, top_k);
67
+
68
+ let dict = PyDict::new(py);
69
+ dict.set_item("precision", metrics.precision)?;
70
+ dict.set_item("recall", metrics.recall)?;
71
+ dict.set_item("f1_score", metrics.f1_score)?;
72
+ dict.set_item("mrr", metrics.mrr)?;
73
+ dict.set_item("ndcg", metrics.ndcg)?;
74
+
75
+ Ok(dict.into())
76
+ }
77
+
78
+ /// Compute embedding quality score (composite metric)
79
+ #[pyfunction]
80
+ fn py_compute_quality_score(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
81
+ if embeddings.is_empty() {
82
+ return Ok(0.0);
83
+ }
84
+
85
+ let isotropy = compute_isotropy(&embeddings);
86
+ let coverage = compute_coverage(&embeddings);
87
+ let distinctiveness = compute_distinctiveness(&embeddings);
88
+
89
+ // Composite score: weighted average
90
+ let quality = (isotropy * 0.4 + coverage * 0.3 + distinctiveness * 0.3).clamp(0.0, 1.0);
91
+
92
+ Ok(quality)
93
+ }
94
+
95
+ /// Quantize a single embedding vector to int8 for storage.
96
+ ///
97
+ /// Returns a dict: `{"values": List[int], "scale": float, "offset": float,
98
+ /// "min": float, "max": float}`. `values` holds signed 8-bit integers
99
+ /// (-128..=127); `scale`/`offset` are required to dequantize.
100
+ #[pyfunction]
101
+ fn py_quantize_vector(py: Python, values: Vec<f32>) -> PyResult<PyObject> {
102
+ let (quantized, params) = quantize(&values);
103
+
104
+ let dict = PyDict::new(py);
105
+ dict.set_item("values", quantized)?;
106
+ dict.set_item("scale", params.scale)?;
107
+ dict.set_item("offset", params.offset)?;
108
+ dict.set_item("min", params.min)?;
109
+ dict.set_item("max", params.max)?;
110
+
111
+ Ok(dict.into())
112
+ }
113
+
114
+ /// Dequantize an int8 vector (produced by `py_quantize_vector`) back to f32.
115
+ #[pyfunction]
116
+ fn py_dequantize_vector(values: Vec<i8>, scale: f32, offset: f32) -> PyResult<Vec<f32>> {
117
+ let params = QuantizationParams {
118
+ scale,
119
+ offset,
120
+ min: 0.0,
121
+ max: 0.0,
122
+ };
123
+ Ok(dequantize(&values, &params))
124
+ }
125
+
126
+ /// Quantize a batch of embedding vectors to int8, one set of quantization
127
+ /// parameters per vector (per-vector min/max scalar quantization).
128
+ ///
129
+ /// Returns a dict: `{"values": List[List[int]], "params": List[dict]}`
130
+ /// where each entry of `params` has the same shape as
131
+ /// `py_quantize_vector`'s return dict.
132
+ #[pyfunction]
133
+ fn py_quantize_batch(py: Python, vectors: Vec<Vec<f32>>) -> PyResult<PyObject> {
134
+ let (values, params) = quantize_batch(&vectors);
135
+
136
+ let values_list = PyList::empty(py);
137
+ for v in &values {
138
+ values_list.append(v)?;
139
+ }
140
+
141
+ let params_list = PyList::empty(py);
142
+ for p in &params {
143
+ params_list.append(params_to_dict(py, p)?)?;
144
+ }
145
+
146
+ let dict = PyDict::new(py);
147
+ dict.set_item("values", values_list)?;
148
+ dict.set_item("params", params_list)?;
149
+
150
+ Ok(dict.into())
151
+ }
152
+
153
+ /// Dequantize a batch of int8 vectors (produced by `py_quantize_batch`)
154
+ /// back to f32, given parallel `scales` and `offsets` lists.
155
+ #[pyfunction]
156
+ fn py_dequantize_batch(
157
+ values: Vec<Vec<i8>>,
158
+ scales: Vec<f32>,
159
+ offsets: Vec<f32>,
160
+ ) -> PyResult<Vec<Vec<f32>>> {
161
+ if values.len() != scales.len() || values.len() != offsets.len() {
162
+ return Err(pyo3::exceptions::PyValueError::new_err(
163
+ "values, scales, and offsets must have the same length",
164
+ ));
165
+ }
166
+
167
+ let params: Vec<QuantizationParams> = scales
168
+ .into_iter()
169
+ .zip(offsets)
170
+ .map(|(scale, offset)| QuantizationParams {
171
+ scale,
172
+ offset,
173
+ min: 0.0,
174
+ max: 0.0,
175
+ })
176
+ .collect();
177
+
178
+ Ok(dequantize_batch(&values, &params))
179
+ }
180
+
181
+ /// PyHound Python module
182
+ #[pymodule]
183
+ fn _core(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
184
+ m.add_function(wrap_pyfunction!(py_compute_isotropy, m)?)?;
185
+ m.add_function(wrap_pyfunction!(py_compute_coverage, m)?)?;
186
+ m.add_function(wrap_pyfunction!(py_compute_distinctiveness, m)?)?;
187
+ m.add_function(wrap_pyfunction!(py_detect_drift, m)?)?;
188
+ m.add_function(wrap_pyfunction!(py_compute_retrieval_metrics, m)?)?;
189
+ m.add_function(wrap_pyfunction!(py_compute_quality_score, m)?)?;
190
+ m.add_function(wrap_pyfunction!(py_quantize_vector, m)?)?;
191
+ m.add_function(wrap_pyfunction!(py_dequantize_vector, m)?)?;
192
+ m.add_function(wrap_pyfunction!(py_quantize_batch, m)?)?;
193
+ m.add_function(wrap_pyfunction!(py_dequantize_batch, m)?)?;
194
+
195
+ Ok(())
196
+ }
@@ -0,0 +1,300 @@
1
+ //! Int8 scalar quantization for vector storage.
2
+ //!
3
+ //! Large local vector indices stored as `Vec<f32>` cost 4 bytes per
4
+ //! dimension. Scalar quantization maps each vector's float components
5
+ //! linearly into the `i8` range (-128..=127), storing them as 1 byte per
6
+ //! dimension plus a small per-vector `(scale, offset)` pair needed to
7
+ //! reconstruct approximate float values. This gives a ~4x memory reduction
8
+ //! for the vector data itself, at the cost of bounded reconstruction error.
9
+ //!
10
+ //! This is *scalar* quantization (per-vector min/max), not product
11
+ //! quantization, and it is not SIMD/GPU accelerated — both are explicitly
12
+ //! out of scope here.
13
+
14
+ use serde::{Deserialize, Serialize};
15
+
16
+ /// Parameters needed to dequantize an `i8` vector back to `f32`.
17
+ ///
18
+ /// Reconstruction: `f32_value = i8_value as f32 * scale + offset`.
19
+ #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20
+ pub struct QuantizationParams {
21
+ /// Multiplier applied to the raw i8 value.
22
+ pub scale: f32,
23
+ /// Additive offset applied after scaling.
24
+ pub offset: f32,
25
+ /// Original minimum value of the vector (kept for introspection).
26
+ pub min: f32,
27
+ /// Original maximum value of the vector (kept for introspection).
28
+ pub max: f32,
29
+ }
30
+
31
+ /// Quantize a single `f32` vector into `i8` values plus the parameters
32
+ /// needed to dequantize it.
33
+ ///
34
+ /// Uses per-vector min/max scalar quantization: the vector's min and max
35
+ /// are mapped to the full `i8` range (-128..=127), and every other value is
36
+ /// linearly interpolated between them. This is standard for embedding
37
+ /// vectors, where per-vector dynamic range is usually similar across a
38
+ /// batch but not identical.
39
+ ///
40
+ /// An empty input returns an empty output with a degenerate
41
+ /// (scale = 0, offset = 0) parameter set.
42
+ ///
43
+ /// A constant vector (min == max, including all-zero) maps every value to
44
+ /// `0i8` and reconstructs exactly via `offset` (scale = 0), so round-trip
45
+ /// error is zero in that case.
46
+ pub fn quantize(values: &[f32]) -> (Vec<i8>, QuantizationParams) {
47
+ if values.is_empty() {
48
+ return (
49
+ Vec::new(),
50
+ QuantizationParams {
51
+ scale: 0.0,
52
+ offset: 0.0,
53
+ min: 0.0,
54
+ max: 0.0,
55
+ },
56
+ );
57
+ }
58
+
59
+ let mut min = values[0];
60
+ let mut max = values[0];
61
+ for &v in values.iter() {
62
+ if v < min {
63
+ min = v;
64
+ }
65
+ if v > max {
66
+ max = v;
67
+ }
68
+ }
69
+
70
+ // Degenerate case: every value identical. Store the value itself as
71
+ // `offset`, use a zero scale, and quantize everything to 0 so
72
+ // dequantization is exact.
73
+ if min == max {
74
+ let params = QuantizationParams {
75
+ scale: 0.0,
76
+ offset: min,
77
+ min,
78
+ max,
79
+ };
80
+ return (vec![0i8; values.len()], params);
81
+ }
82
+
83
+ // Map [min, max] -> [-128, 127] (255 steps).
84
+ let scale = (max - min) / 255.0;
85
+ let offset = min + scale * 128.0; // value that maps to raw i8 = 0
86
+
87
+ let quantized: Vec<i8> = values
88
+ .iter()
89
+ .map(|&v| {
90
+ let raw = ((v - min) / scale).round() - 128.0;
91
+ raw.clamp(-128.0, 127.0) as i8
92
+ })
93
+ .collect();
94
+
95
+ let params = QuantizationParams {
96
+ scale,
97
+ offset,
98
+ min,
99
+ max,
100
+ };
101
+
102
+ (quantized, params)
103
+ }
104
+
105
+ /// Dequantize `i8` values back into approximate `f32` values using the
106
+ /// scale/offset produced by [`quantize`].
107
+ pub fn dequantize(values: &[i8], params: &QuantizationParams) -> Vec<f32> {
108
+ values
109
+ .iter()
110
+ .map(|&q| q as f32 * params.scale + params.offset)
111
+ .collect()
112
+ }
113
+
114
+ /// Quantize a batch of vectors independently (one `QuantizationParams` per
115
+ /// vector). This is the typical entry point for shrinking a whole local
116
+ /// vector index.
117
+ pub fn quantize_batch(vectors: &[Vec<f32>]) -> (Vec<Vec<i8>>, Vec<QuantizationParams>) {
118
+ let mut out_values = Vec::with_capacity(vectors.len());
119
+ let mut out_params = Vec::with_capacity(vectors.len());
120
+ for v in vectors {
121
+ let (q, p) = quantize(v);
122
+ out_values.push(q);
123
+ out_params.push(p);
124
+ }
125
+ (out_values, out_params)
126
+ }
127
+
128
+ /// Dequantize a batch of vectors produced by [`quantize_batch`].
129
+ pub fn dequantize_batch(values: &[Vec<i8>], params: &[QuantizationParams]) -> Vec<Vec<f32>> {
130
+ values
131
+ .iter()
132
+ .zip(params.iter())
133
+ .map(|(v, p)| dequantize(v, p))
134
+ .collect()
135
+ }
136
+
137
+ #[cfg(test)]
138
+ mod tests {
139
+ use super::*;
140
+
141
+ /// Deterministic pseudo-random f32 generator (xorshift) so tests don't
142
+ /// need an external `rand` dependency.
143
+ fn pseudo_random_floats(seed: u64, n: usize, lo: f32, hi: f32) -> Vec<f32> {
144
+ let mut state = seed.wrapping_add(0x9E3779B97F4A7C15);
145
+ (0..n)
146
+ .map(|_| {
147
+ // xorshift64
148
+ state ^= state << 13;
149
+ state ^= state >> 7;
150
+ state ^= state << 17;
151
+ let unit = (state % 1_000_000) as f32 / 1_000_000.0; // [0, 1)
152
+ lo + unit * (hi - lo)
153
+ })
154
+ .collect()
155
+ }
156
+
157
+ fn max_abs_error(original: &[f32], reconstructed: &[f32]) -> f32 {
158
+ original
159
+ .iter()
160
+ .zip(reconstructed.iter())
161
+ .map(|(a, b)| (a - b).abs())
162
+ .fold(0.0f32, f32::max)
163
+ }
164
+
165
+ #[test]
166
+ fn test_round_trip_unit_range() {
167
+ // Typical normalized-embedding range [-1, 1].
168
+ let values = pseudo_random_floats(42, 384, -1.0, 1.0);
169
+ let (quantized, params) = quantize(&values);
170
+ assert_eq!(quantized.len(), values.len());
171
+
172
+ let reconstructed = dequantize(&quantized, &params);
173
+ let err = max_abs_error(&values, &reconstructed);
174
+
175
+ // Expected bound for 8-bit quantization: half a quantization step,
176
+ // i.e. range / 255 (using <= with a small epsilon for float
177
+ // rounding slack).
178
+ let expected_bound = (params.max - params.min) / 255.0;
179
+ assert!(
180
+ err <= expected_bound + 1e-4,
181
+ "max abs error {err} exceeded expected bound {expected_bound}"
182
+ );
183
+ }
184
+
185
+ #[test]
186
+ fn test_round_trip_zero_to_one_range() {
187
+ // Typical unnormalized embedding range [0, 1].
188
+ let values = pseudo_random_floats(7, 768, 0.0, 1.0);
189
+ let (quantized, params) = quantize(&values);
190
+ let reconstructed = dequantize(&quantized, &params);
191
+ let err = max_abs_error(&values, &reconstructed);
192
+
193
+ let expected_bound = (params.max - params.min) / 255.0;
194
+ assert!(
195
+ err <= expected_bound + 1e-4,
196
+ "max abs error {err} exceeded expected bound {expected_bound}"
197
+ );
198
+ }
199
+
200
+ #[test]
201
+ fn test_round_trip_wide_range() {
202
+ // Wider range to make sure scale/offset math generalizes beyond
203
+ // [-1, 1] / [0, 1].
204
+ let values = pseudo_random_floats(1234, 512, -50.0, 75.0);
205
+ let (quantized, params) = quantize(&values);
206
+ let reconstructed = dequantize(&quantized, &params);
207
+ let err = max_abs_error(&values, &reconstructed);
208
+
209
+ let expected_bound = (params.max - params.min) / 255.0;
210
+ assert!(
211
+ err <= expected_bound + 1e-4,
212
+ "max abs error {err} exceeded expected bound {expected_bound}"
213
+ );
214
+ }
215
+
216
+ #[test]
217
+ fn test_all_zero_vector_is_exact() {
218
+ let values = vec![0.0f32; 128];
219
+ let (quantized, params) = quantize(&values);
220
+ assert!(quantized.iter().all(|&q| q == 0));
221
+
222
+ let reconstructed = dequantize(&quantized, &params);
223
+ let err = max_abs_error(&values, &reconstructed);
224
+ assert_eq!(err, 0.0, "all-zero vector should round-trip exactly");
225
+ }
226
+
227
+ #[test]
228
+ fn test_single_value_constant_vector_is_exact() {
229
+ let values = vec![3.5f32; 64];
230
+ let (quantized, params) = quantize(&values);
231
+ let reconstructed = dequantize(&quantized, &params);
232
+ let err = max_abs_error(&values, &reconstructed);
233
+ assert_eq!(err, 0.0, "constant vector should round-trip exactly");
234
+ assert!(reconstructed.iter().all(|&v| v == 3.5));
235
+ }
236
+
237
+ #[test]
238
+ fn test_single_element_vector() {
239
+ let values = vec![-0.234f32];
240
+ let (quantized, params) = quantize(&values);
241
+ assert_eq!(quantized.len(), 1);
242
+ let reconstructed = dequantize(&quantized, &params);
243
+ // A single element is trivially min == max -> exact round trip.
244
+ assert_eq!(reconstructed[0], values[0]);
245
+ }
246
+
247
+ #[test]
248
+ fn test_empty_vector() {
249
+ let values: Vec<f32> = Vec::new();
250
+ let (quantized, params) = quantize(&values);
251
+ assert!(quantized.is_empty());
252
+ let reconstructed = dequantize(&quantized, &params);
253
+ assert!(reconstructed.is_empty());
254
+ }
255
+
256
+ #[test]
257
+ fn test_extremes_map_to_i8_bounds() {
258
+ let values = vec![-1.0f32, 0.0, 0.5, 1.0];
259
+ let (quantized, _params) = quantize(&values);
260
+ assert_eq!(*quantized.first().unwrap(), -128);
261
+ assert_eq!(*quantized.last().unwrap(), 127);
262
+ }
263
+
264
+ #[test]
265
+ fn test_memory_footprint_is_4x_smaller() {
266
+ // i8 storage is 1 byte per dim vs 4 bytes for f32 -- this is the
267
+ // whole point of scalar quantization for large local indices.
268
+ let values = pseudo_random_floats(99, 1536, -1.0, 1.0);
269
+ let (quantized, _params) = quantize(&values);
270
+
271
+ let f32_bytes = values.len() * std::mem::size_of::<f32>();
272
+ let i8_bytes = quantized.len() * std::mem::size_of::<i8>();
273
+
274
+ assert_eq!(f32_bytes, i8_bytes * 4);
275
+ }
276
+
277
+ #[test]
278
+ fn test_quantize_batch_round_trip() {
279
+ let vectors = vec![
280
+ pseudo_random_floats(1, 256, -1.0, 1.0),
281
+ pseudo_random_floats(2, 256, 0.0, 1.0),
282
+ vec![0.0; 256],
283
+ ];
284
+ let (quantized, params) = quantize_batch(&vectors);
285
+ let reconstructed = dequantize_batch(&quantized, &params);
286
+
287
+ for (orig, recon) in vectors.iter().zip(reconstructed.iter()) {
288
+ let err = max_abs_error(orig, recon);
289
+ let bound = {
290
+ let min = orig.iter().cloned().fold(f32::INFINITY, f32::min);
291
+ let max = orig.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
292
+ (max - min) / 255.0
293
+ };
294
+ assert!(
295
+ err <= bound + 1e-4,
296
+ "batch round-trip error {err} exceeded bound {bound}"
297
+ );
298
+ }
299
+ }
300
+ }
@@ -1,91 +0,0 @@
1
- // PyHound Core - Rust implementation of retrieval diagnostics
2
- //
3
- // High-performance embedding quality metrics, pipeline analysis, and diagnostics.
4
-
5
- mod metrics;
6
- mod retrieval_ranking;
7
-
8
- use metrics::{
9
- compute_coverage, compute_distinctiveness, compute_isotropy, compute_retrieval_metrics,
10
- detect_drift,
11
- };
12
- use pyo3::prelude::*;
13
- use pyo3::types::PyDict;
14
- use pyo3::Bound;
15
-
16
- /// Compute isotropy of embeddings (vector space utilization)
17
- #[pyfunction]
18
- fn py_compute_isotropy(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
19
- Ok(compute_isotropy(&embeddings))
20
- }
21
-
22
- /// Compute coverage of embeddings (diversity of semantic space)
23
- #[pyfunction]
24
- fn py_compute_coverage(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
25
- Ok(compute_coverage(&embeddings))
26
- }
27
-
28
- /// Compute distinctiveness of embeddings (semantic separation)
29
- #[pyfunction]
30
- fn py_compute_distinctiveness(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
31
- Ok(compute_distinctiveness(&embeddings))
32
- }
33
-
34
- /// Detect drift in embedding quality
35
- #[pyfunction]
36
- fn py_detect_drift(
37
- baseline_embeddings: Vec<Vec<f32>>,
38
- current_embeddings: Vec<Vec<f32>>,
39
- ) -> PyResult<f32> {
40
- Ok(detect_drift(&baseline_embeddings, &current_embeddings))
41
- }
42
-
43
- /// Compute retrieval metrics (precision, recall, F1, MRR, NDCG)
44
- #[pyfunction]
45
- fn py_compute_retrieval_metrics(
46
- py: Python,
47
- retrieved: Vec<usize>,
48
- relevant: Vec<usize>,
49
- top_k: usize,
50
- ) -> PyResult<PyObject> {
51
- let metrics = compute_retrieval_metrics(&retrieved, &relevant, top_k);
52
-
53
- let dict = PyDict::new(py);
54
- dict.set_item("precision", metrics.precision)?;
55
- dict.set_item("recall", metrics.recall)?;
56
- dict.set_item("f1_score", metrics.f1_score)?;
57
- dict.set_item("mrr", metrics.mrr)?;
58
- dict.set_item("ndcg", metrics.ndcg)?;
59
-
60
- Ok(dict.into())
61
- }
62
-
63
- /// Compute embedding quality score (composite metric)
64
- #[pyfunction]
65
- fn py_compute_quality_score(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
66
- if embeddings.is_empty() {
67
- return Ok(0.0);
68
- }
69
-
70
- let isotropy = compute_isotropy(&embeddings);
71
- let coverage = compute_coverage(&embeddings);
72
- let distinctiveness = compute_distinctiveness(&embeddings);
73
-
74
- // Composite score: weighted average
75
- let quality = (isotropy * 0.4 + coverage * 0.3 + distinctiveness * 0.3).clamp(0.0, 1.0);
76
-
77
- Ok(quality)
78
- }
79
-
80
- /// PyHound Python module
81
- #[pymodule]
82
- fn _core(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
83
- m.add_function(wrap_pyfunction!(py_compute_isotropy, m)?)?;
84
- m.add_function(wrap_pyfunction!(py_compute_coverage, m)?)?;
85
- m.add_function(wrap_pyfunction!(py_compute_distinctiveness, m)?)?;
86
- m.add_function(wrap_pyfunction!(py_detect_drift, m)?)?;
87
- m.add_function(wrap_pyfunction!(py_compute_retrieval_metrics, m)?)?;
88
- m.add_function(wrap_pyfunction!(py_compute_quality_score, m)?)?;
89
-
90
- Ok(())
91
- }
File without changes
File without changes
File without changes
File without changes
File without changes