benchcraft-clean 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,242 @@
1
+ Metadata-Version: 2.4
2
+ Name: benchcraft-clean
3
+ Version: 0.1.0
4
+ Summary: Benchcraft Data-Quality: ONNX Runtime (PyTorch-free) text embeddings feeding naive cosine-similarity near-duplicate detection (D4 scaffold).
5
+ Author: Benchcraft
6
+ License: MIT
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: numpy>=1.24
12
+ Requires-Dist: onnx>=1.15
13
+ Requires-Dist: onnxruntime>=1.17
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # benchcraft-clean
19
+
20
+ A scaffold-depth implementation of one signature capability from
21
+ Benchcraft's LazyClean module (architecture doc Part 3, "Module 2:
22
+ LazyClean"): **embedding generation via native ONNX Runtime feeding a
23
+ near-duplicate detection check** -- a minimal version of the
24
+ Density-Based Semantic Deduplication (D4) idea.
25
+
26
+ ## What this package does (and doesn't) implement
27
+
28
+ This is a **scaffold-depth pass, not a full implementation** of LazyClean.
29
+ In scope:
30
+
31
+ 1. Embed a batch of text rows via an ONNX Runtime session
32
+ (`benchcraft_lazyclean.embeddings`).
33
+ 2. Flag near-duplicate row-index pairs via cosine-similarity thresholding
34
+ over those embeddings (`benchcraft_lazyclean.dedup`).
35
+
36
+ Explicitly **out of scope** for this pass (tracked as future work per the
37
+ architecture doc, not silently dropped):
38
+
39
+ - The **IVF-HNSW approximate-nearest-neighbor index** and **spherical
40
+ mini-batch k-means** clustering step that the real D4 design uses to
41
+ avoid O(n²) pairwise cosine-similarity cost at scale.
42
+ - The **DeCoLe tabular label-error detector** (per-subpopulation confident
43
+ learning).
44
+ - The **train/test contamination auditor**.
45
+ - The aggregate **"Dataset Integrity Score"**.
46
+
47
+ ## Zero-vector rows: "not comparable", not a silent duplicate or distinct call
48
+
49
+ `hashing_bag_of_words_vectorizer` tokenizes with a simple `[a-z0-9]+` regex.
50
+ Any text with **zero regex-matching tokens** -- not just genuinely empty or
51
+ whitespace-only strings, but also punctuation-only text (`"!!!"`, `"???"`)
52
+ and non-ASCII text the regex can't match (`"日本語"`) -- embeds to the
53
+ identical all-zero vector. A zero embedding means *the vectorizer extracted
54
+ no features*, not that the source rows are equal, and not that they're
55
+ distinct either -- a hashing bag-of-words vectorizer with zero extracted
56
+ features genuinely has no basis to compare two such rows.
57
+
58
+ `cosine_similarity_matrix` and `find_near_duplicates` treat this honestly:
59
+ every pairwise entry involving at least one zero-vector row (including a
60
+ zero-vector row against itself, and against a genuinely non-zero row) is
61
+ `nan` -- undefined, not silently `0.0` and not silently `1.0` -- and
62
+ `find_near_duplicates` never flags such a pair as a duplicate by score.
63
+ Instead, `DedupReport.zero_vector_row_indices` lists every row that
64
+ produced no extractable features and could not be compared at all, as a
65
+ third category distinct from both "confirmed duplicate" and "confirmed
66
+ distinct" rows in `report.pairs`/`report.flagged_indices()`.
67
+
68
+ This is itself the fix for two earlier bugs that got this wrong in
69
+ opposite directions: originally, two genuinely-empty rows read similarity
70
+ `0.0` against each other and were silently missed as duplicates; a
71
+ follow-up fix over-corrected by making *any* two zero-vector rows read
72
+ `1.0`, which falsely flagged unrelated zero-feature rows (e.g. `"!!!"` and
73
+ `"???"`) as duplicates of each other at every valid threshold. Neither
74
+ silent guess is right -- reporting "not comparable" separately is the
75
+ honest answer given what this vectorizer can actually tell you. Genuinely
76
+ identical non-empty text is unaffected by any of this and is still
77
+ correctly flagged as a duplicate via its (non-zero) embedding vector.
78
+
79
+ ## The naive O(n²) caveat
80
+
81
+ `dedup.py`'s `find_near_duplicates` computes the **full pairwise
82
+ cosine-similarity matrix** over the embedded batch (`cosine_similarity_matrix`)
83
+ and scans it directly. This is O(n²) in both time and memory. It is a
84
+ correct, simple stand-in for the ANN index at this scaffold's small-batch
85
+ depth, but it is **not** the production path -- the architecture doc calls
86
+ out IVF-HNSW specifically to avoid this cost once dataset sizes grow past a
87
+ small batch. Replacing this brute-force check with a real IVF-HNSW index is
88
+ explicit follow-up work, not implemented here.
89
+
90
+ ## The PyTorch-free / <100MB constraint, and why
91
+
92
+ Per the architecture doc (Part 3, Appendix A) and `CLAUDE.md`'s packaging
93
+ section (§2.7): LazyClean is deliberately **PyTorch-free**. This package's
94
+ runtime dependencies are exactly `onnxruntime`, `onnx`, `numpy`, and
95
+ `lazycore` -- **no `torch`, no `transformers`, anywhere, including optional
96
+ extras.** This is enforced as a hard rule (see `tests/test_embeddings.py::test_no_pytorch_or_transformers_imported`),
97
+ not a soft preference, because:
98
+
99
+ - PyTorch + HuggingFace `transformers` together pull in a multi-hundred-MB
100
+ to multi-GB dependency tree (CUDA/MPS backends, tokenizer binaries,
101
+ model-hub client code). LazyClean's design target is to stay under
102
+ **~100MB** total footprint so a data-cleaning pass doesn't require
103
+ installing the platform's heaviest dependency stack just to check for
104
+ duplicate rows.
105
+ - It is also this module's specific differentiator against the PyTorch/HF
106
+ stack every other embedding-generation tool in this space defaults to.
107
+
108
+ Embeddings are produced by loading a `.onnx` model file directly via the
109
+ `onnxruntime` Python package and running lightweight, tokenizer-adjacent
110
+ preprocessing ourselves in plain Python/NumPy (see
111
+ `hashing_bag_of_words_vectorizer` in `embeddings.py`) -- never via
112
+ `AutoTokenizer`/`AutoModel` or any `transformers` call.
113
+
114
+ ## Solving "we need a real `.onnx` model" without network access or a bundled checkpoint
115
+
116
+ Real sentence-embedding ONNX models are tens to hundreds of MB, which is
117
+ wrong to check into this repo and wrong to require for `pytest` to pass in
118
+ an offline CI environment. This package handles that with two paths:
119
+
120
+ 1. **Hermetic (used by tests and the example):**
121
+ `embeddings.build_synthetic_embedding_onnx` hand-builds a tiny ONNX
122
+ graph on the fly, directly via the `onnx` package's graph-builder API
123
+ (`onnx.helper`/`onnx.numpy_helper`) -- a linear projection plus L2
124
+ normalization over a hashed bag-of-words feature vector. No network
125
+ access, no multi-hundred-MB file, deterministic given a seed.
126
+ `embeddings.build_synthetic_embedding_model()` wraps this plus the
127
+ default preprocessor into a ready-to-use `EmbeddingModel` in one call.
128
+ This is **not** a semantically meaningful sentence embedding -- it exists
129
+ solely to exercise the embed → cosine-similarity dedup pipeline
130
+ end-to-end in tests and the example without any external dependency.
131
+ 2. **Production (documented, not exercised by tests):** see "Wiring in a
132
+ real production model" below.
133
+
134
+ ### Wiring in a real production model
135
+
136
+ `benchcraft_lazyclean.embeddings.MODEL_ALLOWLIST` (a per-module
137
+ `lazycore.licensing.Allowlist` instance, per architecture doc §2.10)
138
+ registers one recommended Tier-1 checkpoint:
139
+
140
+ ```python
141
+ from benchcraft_lazyclean.embeddings import MODEL_ALLOWLIST
142
+ entry = MODEL_ALLOWLIST.check("Xenova/all-MiniLM-L6-v2")
143
+ # ModelLicenseEntry(name='Xenova/all-MiniLM-L6-v2', tier=ModelTier.TIER_1,
144
+ # license_identifier='Apache-2.0', ...)
145
+ ```
146
+
147
+ **Xenova/all-MiniLM-L6-v2** is an ONNX-exported build of
148
+ `sentence-transformers/all-MiniLM-L6-v2` (384-dim mean-pooled embeddings,
149
+ ~90MB fp32 / ~23MB int8-quantized -- comfortably under the <100MB target),
150
+ Apache-2.0 licensed, auto-usable under the Tier-1 policy with no opt-in
151
+ gate. It is **not bundled** with this package. `embeddings.download_recommended_model()`
152
+ is the optional, lazy download path (never called by tests or the example,
153
+ matching the platform's local-only-by-default posture) that fetches and
154
+ caches its `.onnx` graph.
155
+
156
+ To actually use it for real inference, pair it with a real tokenizer
157
+ instead of the default `hashing_bag_of_words_vectorizer`:
158
+
159
+ ```python
160
+ from pathlib import Path
161
+ from tokenizers import Tokenizer # standalone Rust-backed tokenizer, no torch/transformers
162
+ from benchcraft_lazyclean.embeddings import EmbeddingModel, download_recommended_model
163
+
164
+ onnx_path = download_recommended_model() # requires network access; opt-in
165
+ tokenizer = Tokenizer.from_pretrained("Xenova/all-MiniLM-L6-v2")
166
+
167
+ def real_preprocessor(text: str):
168
+ encoding = tokenizer.encode(text)
169
+ # Feed input_ids/attention_mask into the model's actual ONNX inputs and
170
+ # mean-pool the token embeddings over attention_mask here -- the exact
171
+ # shape depends on the checkpoint's input signature (use
172
+ # onnxruntime.InferenceSession(...).get_inputs() to inspect it).
173
+ ...
174
+
175
+ model = EmbeddingModel.from_onnx_file(
176
+ onnx_path, preprocessor=real_preprocessor, embedding_dim=384,
177
+ )
178
+ ```
179
+
180
+ `tokenizers` (the standalone HuggingFace tokenizer library, Rust-backed,
181
+ **no PyTorch/`transformers` dependency**) is a fine choice here per the
182
+ task's constraints, but it is deliberately **not** a hard dependency of
183
+ this package -- it is only needed if you wire in a real subword-tokenized
184
+ model. The default hashing preprocessor has zero extra dependencies.
185
+
186
+ ## Public API
187
+
188
+ ```python
189
+ from benchcraft_lazyclean import (
190
+ EmbeddingModel, # wraps an onnxruntime.InferenceSession + preprocessor
191
+ build_synthetic_embedding_model, # hermetic test/example fixture
192
+ detect_near_duplicate_text, # rows -> (embeddings, DedupReport)
193
+ find_near_duplicates, # embeddings -> DedupReport (naive O(n^2))
194
+ DedupReport, DuplicatePair,
195
+ MODEL_ALLOWLIST, # lazycore.licensing.Allowlist for this module
196
+ )
197
+
198
+ model = build_synthetic_embedding_model()
199
+ embeddings, report = detect_near_duplicate_text(
200
+ ["some text", "some txt", "totally different"], model, threshold=0.9,
201
+ )
202
+ for pair in report.pairs:
203
+ print(pair.index_a, pair.index_b, pair.similarity)
204
+
205
+ # Rows that produced no extractable features (e.g. empty/punctuation-only/
206
+ # non-ASCII text under the hashing vectorizer) are reported separately --
207
+ # see "Zero-vector rows" above -- rather than silently folded into `pairs`.
208
+ print("could not compare:", report.zero_vector_row_indices)
209
+ ```
210
+
211
+ `detect_near_duplicate_text` accepts a plain `Iterable[str]`, or a Tier-1
212
+ Arrow-backed `pandas.Series` / `polars.Series` (a single text column), per
213
+ `lazycore.data`'s §2.1 conventions -- it uses
214
+ `lazycore.data.is_arrow_backed_pandas` to check (and warn, not fail, if
215
+ not) rather than re-implementing that check.
216
+
217
+ ## Installation
218
+
219
+ `lazycore` is a local sibling package under `packages/lazycore` and is not
220
+ declared as a formal `pyproject.toml` dependency (hatchling/pip have no
221
+ portable relative-path dependency syntax) -- install it first, matching the
222
+ convention already established by `packages/automl`:
223
+
224
+ ```bash
225
+ pip install -e packages/lazycore
226
+ pip install -e "packages/lazyclean[dev]"
227
+ ```
228
+
229
+ ## Running tests
230
+
231
+ ```bash
232
+ pytest packages/lazyclean/tests
233
+ ```
234
+
235
+ Fully hermetic -- no network access required. Uses
236
+ `build_synthetic_embedding_model()` throughout.
237
+
238
+ ## Running the example
239
+
240
+ ```bash
241
+ python packages/lazyclean/examples/dedup_example.py
242
+ ```
@@ -0,0 +1,6 @@
1
+ benchcraft_lazyclean/__init__.py,sha256=TRm5ek7dQbfcSlIjG40LkCTWjqj9xvO5E71ZVfjEifI,5357
2
+ benchcraft_lazyclean/dedup.py,sha256=TA_GqVcQfJvmXq7KSZDlDiO8galyje8Fv6mLthSrRRw,8565
3
+ benchcraft_lazyclean/embeddings.py,sha256=nrk72hGbdhbUowWXNXgXEcxsiBvdTvQe4LNeI7Jazes,20381
4
+ benchcraft_clean-0.1.0.dist-info/METADATA,sha256=4cfzuucC-7dh7AsfNAaGWnNFOszuyDc4zEPvNmIc9is,11230
5
+ benchcraft_clean-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ benchcraft_clean-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,139 @@
1
+ """benchcraft_lazyclean -- LazyClean scaffold: ONNX Runtime embeddings + near-duplicate detection.
2
+
3
+ Public API surface for the one signature capability implemented at this
4
+ scaffold depth (architecture doc Part 3, "Module 2: LazyClean"): embed a
5
+ batch of text rows via native ONNX Runtime (no PyTorch, no `transformers`),
6
+ then flag near-duplicate row pairs via cosine-similarity thresholding over
7
+ those embeddings -- a minimal stand-in for the Density-Based Semantic
8
+ Deduplication (D4) idea. See ``dedup.py`` for the explicit naive-O(n^2)
9
+ scope boundary and ``embeddings.py`` for the PyTorch-free embedding path.
10
+
11
+ Everything else described for LazyClean in the architecture doc (the
12
+ IVF-HNSW ANN index, spherical mini-batch k-means, the DeCoLe tabular
13
+ label-error detector, train/test contamination auditing, the aggregate
14
+ "Dataset Integrity Score") is out of scope for this pass -- see README.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import warnings
20
+ from typing import TYPE_CHECKING
21
+
22
+ import numpy as np
23
+
24
+ from lazycore.data import is_arrow_backed_pandas
25
+
26
+ from .dedup import DedupReport, DuplicatePair, cosine_similarity_matrix, find_near_duplicates
27
+ from .embeddings import (
28
+ MODEL_ALLOWLIST,
29
+ RECOMMENDED_MODEL_NAME,
30
+ EmbeddingModel,
31
+ build_synthetic_embedding_model,
32
+ build_synthetic_embedding_onnx,
33
+ download_recommended_model,
34
+ hashing_bag_of_words_vectorizer,
35
+ )
36
+
37
+ if TYPE_CHECKING: # pragma: no cover - type-checking-only imports
38
+ import pandas as pd
39
+ import polars as pl
40
+
41
+ __all__ = [
42
+ "EmbeddingModel",
43
+ "MODEL_ALLOWLIST",
44
+ "RECOMMENDED_MODEL_NAME",
45
+ "build_synthetic_embedding_model",
46
+ "build_synthetic_embedding_onnx",
47
+ "download_recommended_model",
48
+ "hashing_bag_of_words_vectorizer",
49
+ "DedupReport",
50
+ "DuplicatePair",
51
+ "cosine_similarity_matrix",
52
+ "find_near_duplicates",
53
+ "detect_near_duplicate_text",
54
+ ]
55
+
56
+ #: Text rows may be supplied as a plain iterable of strings, or as a
57
+ #: Tier-1 Arrow-backed column (a pandas Series or a Polars Series), per
58
+ #: lazycore.data's §2.1 conventions.
59
+ TextRows = "Iterable[str] | pd.Series | pl.Series"
60
+
61
+
62
+ def _coerce_text_rows(rows: object) -> list[str]:
63
+ """Normalize the accepted input shapes down to a plain ``list[str]``.
64
+
65
+ Accepts a plain iterable of strings, or a Tier-1 Arrow-backed pandas
66
+ Series / Polars Series (see ``lazycore.data``, architecture doc §2.1).
67
+ A pandas Series that is *not* Arrow-backed is still accepted (this
68
+ package does not hard-require Tier-1 storage to function), but emits a
69
+ warning pointing at the convention, using
70
+ ``lazycore.data.is_arrow_backed_pandas`` for the check -- reusing
71
+ lazycore's Tier-1 helper rather than re-implementing an Arrow-dtype
72
+ check here.
73
+ """
74
+ try:
75
+ import pandas as pd
76
+ except ImportError:
77
+ pd = None # type: ignore[assignment]
78
+
79
+ if pd is not None and isinstance(rows, pd.DataFrame):
80
+ raise TypeError(
81
+ "Expected a single text column (a pandas Series), not a full "
82
+ "DataFrame. Pass e.g. `frame['text_column']`."
83
+ )
84
+
85
+ if pd is not None and isinstance(rows, pd.Series):
86
+ if not is_arrow_backed_pandas(rows.to_frame()):
87
+ warnings.warn(
88
+ "Input pandas Series is not Arrow-backed (pandas 2.x "
89
+ "ArrowDtype). benchcraft_lazyclean follows lazycore's Tier-1 "
90
+ "convention (architecture doc §2.1) for zero-copy interop "
91
+ "across Benchcraft modules; consider "
92
+ "`series.convert_dtypes(dtype_backend='pyarrow')`. "
93
+ "Proceeding anyway -- this is not a hard requirement.",
94
+ stacklevel=3,
95
+ )
96
+ return [str(value) for value in rows.tolist()]
97
+
98
+ try:
99
+ import polars as pl
100
+ except ImportError:
101
+ pl = None # type: ignore[assignment]
102
+
103
+ if pl is not None and isinstance(rows, pl.Series):
104
+ return [str(value) for value in rows.to_list()]
105
+
106
+ return [str(value) for value in rows] # type: ignore[union-attr]
107
+
108
+
109
+ def detect_near_duplicate_text(
110
+ rows: object,
111
+ model: EmbeddingModel,
112
+ *,
113
+ threshold: float = 0.92,
114
+ ) -> tuple[np.ndarray, DedupReport]:
115
+ """Embed ``rows`` via ONNX Runtime and flag near-duplicate row pairs.
116
+
117
+ This is the one canonical entrypoint tying the embedding path
118
+ (``embeddings.py``) to the dedup path (``dedup.py``) together -- prefer
119
+ it over calling ``model.embed`` + ``find_near_duplicates`` separately
120
+ unless you need the embeddings for something else in between.
121
+
122
+ Args:
123
+ rows: an iterable of text strings, or a Tier-1 Arrow-backed pandas
124
+ Series / Polars Series (a single text column).
125
+ model: an :class:`EmbeddingModel` (e.g. from
126
+ :func:`build_synthetic_embedding_model` for hermetic use, or a
127
+ real production model wired via
128
+ :meth:`EmbeddingModel.from_onnx_file`).
129
+ threshold: cosine-similarity cutoff in ``(0.0, 1.0]`` -- see
130
+ :func:`find_near_duplicates`.
131
+
132
+ Returns:
133
+ ``(embeddings, report)`` -- the ``(n_rows, embedding_dim)`` float32
134
+ embedding array and the :class:`DedupReport` of flagged pairs.
135
+ """
136
+ texts = _coerce_text_rows(rows)
137
+ embeddings = model.embed(texts)
138
+ report = find_near_duplicates(embeddings, threshold=threshold)
139
+ return embeddings, report
@@ -0,0 +1,187 @@
1
+ """Cosine-similarity near-duplicate detection over embeddings.
2
+
3
+ This is a minimal stand-in for the Density-Based Semantic Deduplication
4
+ (D4) capability described in the architecture doc's Part 3 ("Module 2:
5
+ LazyClean"). The real D4 design uses spherical mini-batch k-means to bucket
6
+ embeddings into clusters, then an IVF-HNSW approximate-nearest-neighbor
7
+ index within/across clusters, specifically to *avoid* O(n^2) pairwise
8
+ cosine-similarity cost at scale.
9
+
10
+ **This module deliberately implements only the naive O(n^2) brute-force
11
+ version.** For a small batch this is a perfectly correct and simple
12
+ stand-in for the ANN index; it does not scale past a few thousand rows
13
+ before the pairwise similarity matrix becomes the bottleneck the
14
+ architecture doc calls out IVF-HNSW as solving. Replacing
15
+ :func:`cosine_similarity_matrix` (and the O(n^2) loop in
16
+ :func:`find_near_duplicates`) with a real IVF-HNSW index is tracked as
17
+ follow-up work, not implemented in this scaffold-depth pass -- see the
18
+ package README for the explicit scope boundary.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+
25
+ import numpy as np
26
+
27
+ __all__ = [
28
+ "DuplicatePair",
29
+ "DedupReport",
30
+ "cosine_similarity_matrix",
31
+ "find_near_duplicates",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class DuplicatePair:
37
+ """One flagged near-duplicate row pair."""
38
+
39
+ index_a: int
40
+ index_b: int
41
+ similarity: float
42
+
43
+
44
+ @dataclass
45
+ class DedupReport:
46
+ """Result of a near-duplicate scan over a batch of embedded rows.
47
+
48
+ ``zero_vector_row_indices`` is a distinct third category alongside
49
+ "flagged as a duplicate pair" and "not flagged" -- see
50
+ :func:`find_near_duplicates` for why an all-zero embedding row cannot be
51
+ scored as either a confirmed duplicate or a confirmed distinct row.
52
+ """
53
+
54
+ pairs: list[DuplicatePair]
55
+ threshold: float
56
+ num_rows: int
57
+ zero_vector_row_indices: list[int] = field(default_factory=list)
58
+
59
+ def flagged_indices(self) -> set[int]:
60
+ """The set of row indices that appear in at least one flagged pair.
61
+
62
+ Rows in :attr:`zero_vector_row_indices` never appear here: a
63
+ zero-vector row's similarity to *anything* (including another
64
+ zero-vector row) is undefined, so it can never be scored above
65
+ ``threshold`` and flagged as a duplicate. Check
66
+ ``zero_vector_row_indices`` separately to see which rows produced no
67
+ extractable features and could not be compared at all.
68
+ """
69
+ flagged: set[int] = set()
70
+ for pair in self.pairs:
71
+ flagged.add(pair.index_a)
72
+ flagged.add(pair.index_b)
73
+ return flagged
74
+
75
+
76
+ def cosine_similarity_matrix(embeddings: np.ndarray) -> np.ndarray:
77
+ """Compute the full pairwise cosine-similarity matrix for ``embeddings``.
78
+
79
+ **Naive O(n^2) implementation.** This materializes an ``(n, n)`` dense
80
+ similarity matrix, which is the exact cost profile the architecture
81
+ doc's IVF-HNSW approximate-nearest-neighbor index (Part 3, "Module 2:
82
+ LazyClean") exists to avoid at scale. Acceptable and simple for the
83
+ small batches this scaffold-depth pass targets; not a substitute for
84
+ that index once real dataset sizes are in play.
85
+
86
+ **Zero-vector rows are undefined, not 0.0 or 1.0.** A genuinely all-zero
87
+ embedding row (e.g. ``hashing_bag_of_words_vectorizer`` on text with no
88
+ regex-matching tokens at all -- empty/whitespace-only text, but also
89
+ punctuation-only text like ``"!!!"``/``"???"`` or non-ASCII text the
90
+ tokenizer's ``[a-z0-9]+`` regex can't match) has no defined direction. A
91
+ zero embedding means the vectorizer extracted *no features*, not that
92
+ the source rows are equal or that they are distinct -- two different
93
+ zero-feature texts (``"!!!"`` and ``"???"``) are not duplicates of each
94
+ other just because they hash to the same all-zero vector, and treating
95
+ that as similarity 1.0 falsely flags unrelated rows as duplicates at
96
+ every valid threshold. Silently reading it as 0.0 (the plain
97
+ normalized-dot-product result) is equally wrong in the other direction:
98
+ it silently misses genuinely-identical zero-feature rows (e.g. two
99
+ empty strings) as duplicates. Both are silent guesses this vectorizer
100
+ has no basis for. This function reports the honest answer instead:
101
+ every entry involving at least one zero-vector row (including a
102
+ zero-vector row against itself) is ``np.nan`` -- "not comparable" --
103
+ rather than a guessed 0.0 or 1.0. Callers that need pairwise duplicate
104
+ decisions should treat ``nan`` as "cannot say" and handle zero-vector
105
+ rows as a separate category (see :func:`find_near_duplicates` and
106
+ :attr:`DedupReport.zero_vector_row_indices`).
107
+ """
108
+ if embeddings.ndim != 2:
109
+ raise ValueError(f"Expected a 2D (n_rows, dim) array, got shape {embeddings.shape!r}.")
110
+ norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
111
+ is_zero_vector = (norms == 0.0).reshape(-1)
112
+ safe_norms = np.where(norms == 0.0, 1.0, norms)
113
+ normalized = embeddings / safe_norms
114
+ similarities = normalized @ normalized.T
115
+
116
+ if is_zero_vector.any():
117
+ # Any pair where *either* row is a zero vector is undefined -- not
118
+ # just the zero-vector-against-zero-vector case. See the docstring
119
+ # above for why silently choosing 0.0 or 1.0 here is wrong either
120
+ # way.
121
+ undefined_mask = np.logical_or.outer(is_zero_vector, is_zero_vector)
122
+ similarities = np.where(undefined_mask, np.nan, similarities)
123
+ return similarities
124
+
125
+
126
+ def find_near_duplicates(embeddings: np.ndarray, *, threshold: float = 0.92) -> DedupReport:
127
+ """Flag row-index pairs whose cosine similarity is at or above ``threshold``.
128
+
129
+ Brute-force O(n^2): computes the full pairwise similarity matrix via
130
+ :func:`cosine_similarity_matrix` and scans its upper triangle. See the
131
+ module docstring for why this is an intentional, documented scope
132
+ boundary rather than the production-scale IVF-HNSW path.
133
+
134
+ **Zero-vector rows are never flagged by similarity score.** A row whose
135
+ embedding is all-zero (see :func:`cosine_similarity_matrix`) has an
136
+ undefined (``nan``) similarity to every other row, including another
137
+ zero-vector row, so it is skipped when scanning for duplicate pairs --
138
+ it can never be silently swept in as a "duplicate" of an unrelated
139
+ zero-vector row, nor silently cleared as "distinct". Instead, every such
140
+ row's index is reported separately in
141
+ :attr:`DedupReport.zero_vector_row_indices`, meaning "this row produced
142
+ no extractable features and could not be compared" -- a third category
143
+ distinct from both "confirmed duplicate" and "confirmed distinct".
144
+
145
+ Args:
146
+ embeddings: ``(n_rows, dim)`` array, typically from
147
+ :meth:`benchcraft_lazyclean.embeddings.EmbeddingModel.embed`.
148
+ threshold: cosine-similarity cutoff in ``(0.0, 1.0]`` above which a
149
+ pair is flagged as a near-duplicate.
150
+
151
+ Returns:
152
+ A :class:`DedupReport` with pairs sorted by descending similarity,
153
+ plus ``zero_vector_row_indices`` for rows that could not be
154
+ compared at all.
155
+ """
156
+ if not (0.0 < threshold <= 1.0):
157
+ raise ValueError(f"threshold must be in (0.0, 1.0], got {threshold!r}.")
158
+ if embeddings.ndim != 2:
159
+ raise ValueError(f"Expected a 2D (n_rows, dim) array, got shape {embeddings.shape!r}.")
160
+ if not np.isfinite(embeddings).all():
161
+ raise ValueError("embeddings must contain only finite values")
162
+
163
+ num_rows = embeddings.shape[0]
164
+ norms = np.linalg.norm(embeddings, axis=1)
165
+ zero_vector_row_indices = [i for i in range(num_rows) if norms[i] == 0.0]
166
+ similarities = cosine_similarity_matrix(embeddings)
167
+
168
+ pairs: list[DuplicatePair] = []
169
+ for i in range(num_rows):
170
+ for j in range(i + 1, num_rows):
171
+ similarity = similarities[i, j]
172
+ if np.isnan(similarity):
173
+ # Undefined -- at least one of these two rows is a zero
174
+ # vector. Not comparable, so never flagged as a duplicate
175
+ # here; see zero_vector_row_indices instead.
176
+ continue
177
+ similarity = float(similarity)
178
+ if similarity >= threshold:
179
+ pairs.append(DuplicatePair(index_a=i, index_b=j, similarity=similarity))
180
+
181
+ pairs.sort(key=lambda pair: pair.similarity, reverse=True)
182
+ return DedupReport(
183
+ pairs=pairs,
184
+ threshold=threshold,
185
+ num_rows=num_rows,
186
+ zero_vector_row_indices=zero_vector_row_indices,
187
+ )
@@ -0,0 +1,442 @@
1
+ """ONNX Runtime text embedding generation (architecture doc Part 3, "Module 2: LazyClean").
2
+
3
+ The one hard constraint this module exists to enforce: embeddings are
4
+ produced by loading a ``.onnx`` model file directly via the ``onnxruntime``
5
+ Python package and running our own lightweight, tokenizer-adjacent
6
+ preprocessing in plain Python/NumPy -- never via PyTorch or the HuggingFace
7
+ ``transformers`` package. That keeps this package's runtime dependency
8
+ footprint under the architecture doc's ~100MB target and avoids pulling in
9
+ the PyTorch/HuggingFace stack, which is LazyClean's specific differentiator
10
+ per Part 3 and Appendix A ("LazyClean"). Do not import ``torch`` or
11
+ ``transformers`` anywhere in this package, including for type hints.
12
+
13
+ Two embedding-model sources are provided:
14
+
15
+ 1. :func:`build_synthetic_embedding_model` -- hand-builds a tiny ONNX graph
16
+ on the fly via the ``onnx`` package's graph-builder API (a linear
17
+ projection + L2 normalization over a hashed bag-of-words feature vector).
18
+ This is fully hermetic (no network access, no bundled multi-hundred-MB
19
+ model file) and is what the test suite and ``examples/dedup_example.py``
20
+ use. It is a stand-in for a real sentence-embedding model, good enough to
21
+ demonstrate the embed -> dedup pipeline end-to-end, and is **not**
22
+ intended to produce semantically meaningful embeddings for production use.
23
+ 2. :func:`download_recommended_model` -- documents (and, given network
24
+ access, performs) the production wiring: fetching the Tier-1-allowlisted
25
+ ONNX sentence-embedding checkpoint referenced in :data:`MODEL_ALLOWLIST`
26
+ and caching it locally. This path is optional and lazy -- it is never
27
+ called by tests or the example, and nothing in this package requires
28
+ network access to import or to run its test suite.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import os
35
+ import re
36
+ import tempfile
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Callable, Iterable, Mapping, Sequence, Union
40
+
41
+ import numpy as np
42
+ import onnx
43
+ import onnxruntime as ort
44
+ from onnx import TensorProto, helper, numpy_helper
45
+
46
+ from lazycore.licensing import Allowlist, ModelTier
47
+
48
+ __all__ = [
49
+ "MODEL_ALLOWLIST",
50
+ "EmbeddingModel",
51
+ "hashing_bag_of_words_vectorizer",
52
+ "build_synthetic_embedding_onnx",
53
+ "build_synthetic_embedding_model",
54
+ "download_recommended_model",
55
+ ]
56
+
57
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
58
+
59
+ #: A preprocessor may return either a single ``(feature_dim,)`` array fed to
60
+ #: the model's one input (:data:`EmbeddingModel.input_name` -- what the
61
+ #: synthetic single-input fixture and ``hashing_bag_of_words_vectorizer``
62
+ #: use), or a mapping of ONNX input name -> ``(feature_dim,)`` array for a
63
+ #: real multi-input sentence-transformer checkpoint (e.g.
64
+ #: ``{"input_ids": ..., "attention_mask": ..., "token_type_ids": ...}``).
65
+ #: See :meth:`EmbeddingModel.embed`.
66
+ PreprocessorOutput = Union[np.ndarray, Mapping[str, np.ndarray]]
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Model licensing allowlist (architecture doc §2.10) -- LazyClean's own
70
+ # instance, per lazycore.licensing's documented per-module ownership pattern.
71
+ # ---------------------------------------------------------------------------
72
+
73
+ #: LazyClean's model-checkpoint allowlist (architecture doc §2.10). Starts
74
+ #: empty per lazycore's contract; LazyClean populates it with the one
75
+ #: production embedding checkpoint this module documents wiring for. This
76
+ #: is a *recommendation*, not a bundled artifact -- see
77
+ #: :func:`download_recommended_model`.
78
+ MODEL_ALLOWLIST = Allowlist()
79
+
80
+ RECOMMENDED_MODEL_NAME = "Xenova/all-MiniLM-L6-v2"
81
+
82
+ MODEL_ALLOWLIST.register(
83
+ name=RECOMMENDED_MODEL_NAME,
84
+ tier=ModelTier.TIER_1,
85
+ license_identifier="Apache-2.0",
86
+ notes=(
87
+ "Recommended production checkpoint for benchcraft_lazyclean.embeddings.EmbeddingModel: "
88
+ "an ONNX-exported sentence-transformer (384-dim mean-pooled embeddings, "
89
+ "~90MB fp32 / ~23MB int8-quantized -- well under this module's <100MB "
90
+ "ONNX Runtime footprint target). Apache-2.0 licensed, auto-usable "
91
+ "(Tier 1), no opt-in gate required. Not bundled with this package and "
92
+ "not downloaded by default -- see download_recommended_model() and the "
93
+ "README's 'Wiring in a real production model' section for how to point "
94
+ "EmbeddingModel.from_onnx_file at a local copy of its model.onnx, paired "
95
+ "with a real WordPiece tokenizer (e.g. via the standalone `tokenizers` "
96
+ "library) and mean-pooling in place of this module's default hashing "
97
+ "bag-of-words preprocessor. Loading it never requires torch or "
98
+ "transformers -- only onnxruntime plus a tokenizer/vocab file."
99
+ ),
100
+ )
101
+
102
+ # A specific, pinned commit of the recommended checkpoint's HF repo -- NOT
103
+ # "main". "main" is a mutable branch ref: the repo owner can force-push a
104
+ # different model (different weights, a different license, or a broken
105
+ # export) to it at any time, silently changing what a previously-verified
106
+ # "Xenova/all-MiniLM-L6-v2" download resolves to. Pinning to an immutable
107
+ # commit SHA means download_recommended_model() always fetches the exact,
108
+ # previously-reviewed artifact this module's Tier-1/Apache-2.0
109
+ # MODEL_ALLOWLIST entry above was actually reviewed against. Re-verify and
110
+ # bump this SHA deliberately (a human decision, not automatic) if the
111
+ # upstream repo needs to be updated.
112
+ _RECOMMENDED_MODEL_REVISION = "751bff37182d3f1213fa05d7196b954e230abad9"
113
+
114
+ # A direct HTTP source for the recommended checkpoint's ONNX export and its
115
+ # tokenizer config. Referenced only by the optional, lazy download path in
116
+ # download_recommended_model() -- never touched by import or by tests.
117
+ _RECOMMENDED_MODEL_ONNX_URL = (
118
+ f"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/"
119
+ f"{_RECOMMENDED_MODEL_REVISION}/onnx/model.onnx"
120
+ )
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Preprocessing ("tokenization-adjacent" -- runs in plain Python/NumPy, not
125
+ # inside the ONNX graph, and never touches PyTorch/transformers).
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def hashing_bag_of_words_vectorizer(vocab_dim: int = 128) -> Callable[[str], np.ndarray]:
130
+ """Return a text -> fixed-length float32 vector feature-hashing function.
131
+
132
+ This is the default preprocessor for the synthetic test/example model.
133
+ It lower-cases and word-tokenizes with a simple regex, hashes each token
134
+ into one of ``vocab_dim`` buckets via SHA-256 (stable across Python
135
+ processes, unlike the builtin ``hash()``), accumulates token counts per
136
+ bucket, and normalizes by token count so sentence length doesn't dominate
137
+ the resulting vector's magnitude. It has no PyTorch/transformers
138
+ dependency and no learned vocabulary -- it is intentionally simple,
139
+ matching the ~O(n^2)-for-now scaffold depth of this package (see
140
+ dedup.py). A real production model (see README) would instead use a
141
+ proper subword tokenizer (e.g. the standalone `tokenizers` library) and
142
+ feed token IDs/attention masks into a transformer ONNX graph.
143
+ """
144
+
145
+ def _tokenize(text: str) -> list[str]:
146
+ return _TOKEN_RE.findall(text.lower())
147
+
148
+ def _vectorize(text: str) -> np.ndarray:
149
+ vector = np.zeros(vocab_dim, dtype=np.float32)
150
+ tokens = _tokenize(text)
151
+ if not tokens:
152
+ return vector
153
+ for token in tokens:
154
+ digest = hashlib.sha256(token.encode("utf-8")).digest()
155
+ bucket = int.from_bytes(digest[:8], "big") % vocab_dim
156
+ vector[bucket] += 1.0
157
+ vector /= float(len(tokens))
158
+ return vector
159
+
160
+ return _vectorize
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Synthetic ONNX graph builder (hermetic test/example fixture)
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def build_synthetic_embedding_onnx(
169
+ path: str | Path,
170
+ *,
171
+ vocab_dim: int = 128,
172
+ embedding_dim: int = 32,
173
+ seed: int = 0,
174
+ ) -> Path:
175
+ """Hand-build a tiny ONNX graph and save it to ``path``.
176
+
177
+ This is a **test/example fixture, not a real embedding model**. It is
178
+ built directly via the ``onnx`` package's graph-builder API
179
+ (``onnx.helper``/``onnx.numpy_helper``) so that tests and the example in
180
+ this package are fully self-contained: no network access is required,
181
+ and no multi-hundred-MB model file needs to be checked into the repo.
182
+
183
+ Graph shape: ``embedding = L2Normalize(input @ weight + bias)``, where
184
+ ``weight``/``bias`` are fixed (seeded) random initializers baked into
185
+ the graph. This is enough to demonstrate the embed -> cosine-similarity
186
+ dedup pipeline end-to-end -- near-identical input text produces
187
+ near-identical hashed feature vectors (see
188
+ :func:`hashing_bag_of_words_vectorizer`), and a linear map (even a
189
+ random one) preserves that similarity closely enough for near-duplicate
190
+ detection to work in tests. It is not a semantically meaningful sentence
191
+ embedding and must not be used for anything beyond tests/examples.
192
+
193
+ Returns the resolved ``Path`` the model was written to.
194
+ """
195
+ path = Path(path)
196
+ path.parent.mkdir(parents=True, exist_ok=True)
197
+
198
+ rng = np.random.default_rng(seed)
199
+ weight = rng.normal(scale=1.0 / np.sqrt(vocab_dim), size=(vocab_dim, embedding_dim)).astype(
200
+ np.float32
201
+ )
202
+ bias = np.zeros((embedding_dim,), dtype=np.float32)
203
+ eps = np.array([1e-12], dtype=np.float32)
204
+
205
+ input_info = helper.make_tensor_value_info("input", TensorProto.FLOAT, [None, vocab_dim])
206
+ output_info = helper.make_tensor_value_info(
207
+ "embedding", TensorProto.FLOAT, [None, embedding_dim]
208
+ )
209
+
210
+ weight_init = numpy_helper.from_array(weight, name="weight")
211
+ bias_init = numpy_helper.from_array(bias, name="bias")
212
+ eps_init = numpy_helper.from_array(eps, name="eps")
213
+
214
+ nodes = [
215
+ helper.make_node("MatMul", ["input", "weight"], ["linear_raw"], name="matmul"),
216
+ helper.make_node("Add", ["linear_raw", "bias"], ["linear_out"], name="add_bias"),
217
+ helper.make_node("Mul", ["linear_out", "linear_out"], ["squared"], name="square"),
218
+ helper.make_node(
219
+ "ReduceSum",
220
+ ["squared"],
221
+ ["sum_squared"],
222
+ name="reduce_sum",
223
+ axes=[1],
224
+ keepdims=1,
225
+ ),
226
+ helper.make_node("Sqrt", ["sum_squared"], ["norm"], name="sqrt"),
227
+ helper.make_node("Add", ["norm", "eps"], ["norm_eps"], name="add_eps"),
228
+ helper.make_node("Div", ["linear_out", "norm_eps"], ["embedding"], name="l2_normalize"),
229
+ ]
230
+
231
+ graph = helper.make_graph(
232
+ nodes,
233
+ "lazyclean_synthetic_embedding",
234
+ [input_info],
235
+ [output_info],
236
+ initializer=[weight_init, bias_init, eps_init],
237
+ )
238
+ # Opset 11: ReduceSum's `axes` is still an attribute here (it moved to
239
+ # an optional second input starting at opset 13), which keeps this
240
+ # graph-construction code simple. All other ops used below (MatMul,
241
+ # Add, Mul, Sqrt, Div) are stable well before and after opset 11.
242
+ model = helper.make_model(
243
+ graph,
244
+ producer_name="benchcraft-lazyclean",
245
+ opset_imports=[helper.make_opsetid("", 11)],
246
+ )
247
+ onnx.checker.check_model(model)
248
+ onnx.save(model, str(path))
249
+ return path
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # EmbeddingModel -- the one canonical embedding path
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ @dataclass
258
+ class EmbeddingModel:
259
+ """Wraps an ``onnxruntime.InferenceSession`` plus a text preprocessor.
260
+
261
+ This is the single canonical way this package turns text into
262
+ embeddings, whether the underlying ``.onnx`` graph is the synthetic
263
+ test fixture from :func:`build_synthetic_embedding_onnx` (single input,
264
+ single output -- e.g. ``hashing_bag_of_words_vectorizer``) or a real
265
+ production sentence-embedding checkpoint (see README), which typically
266
+ has *multiple* named inputs (``input_ids``/``attention_mask``/
267
+ ``token_type_ids``). There is deliberately no second/parallel embedding
268
+ code path -- both shapes go through :attr:`preprocessor` and
269
+ :meth:`embed` below; see :data:`PreprocessorOutput` for how a
270
+ preprocessor selects between them.
271
+ """
272
+
273
+ session: ort.InferenceSession
274
+ input_name: str
275
+ output_name: str
276
+ preprocessor: Callable[[str], PreprocessorOutput]
277
+ embedding_dim: int
278
+
279
+ @classmethod
280
+ def from_onnx_file(
281
+ cls,
282
+ model_path: str | Path,
283
+ *,
284
+ preprocessor: Callable[[str], PreprocessorOutput],
285
+ embedding_dim: int,
286
+ input_name: str | None = None,
287
+ output_name: str | None = None,
288
+ providers: Sequence[str] | None = None,
289
+ ) -> "EmbeddingModel":
290
+ """Load a ``.onnx`` embedding model via ``onnxruntime`` directly.
291
+
292
+ No PyTorch, no `transformers` -- ``onnxruntime.InferenceSession`` is
293
+ the only inference runtime this package ever touches.
294
+
295
+ ``input_name`` (like ``session.get_inputs()[0].name`` inferred by
296
+ default) only matters for a **single-input** graph, i.e. when
297
+ ``preprocessor`` returns a plain array per row -- see
298
+ :meth:`embed`. A real multi-input sentence-transformer ONNX
299
+ checkpoint has more than one required input
300
+ (``input_ids``/``attention_mask``/``token_type_ids``, typically);
301
+ for that case, write ``preprocessor`` to return a
302
+ ``{input_name: array}`` mapping instead, and this inferred/passed
303
+ ``input_name`` is simply unused (the mapping's own keys are fed to
304
+ the session directly). This function does not validate that a
305
+ single inferred input name covers every input the graph actually
306
+ requires -- if you load a multi-input model with a preprocessor
307
+ that returns a single array, ``onnxruntime`` will raise a missing-
308
+ input error at ``embed()`` time, not here.
309
+ """
310
+ session = ort.InferenceSession(
311
+ str(model_path), providers=list(providers) if providers else ["CPUExecutionProvider"]
312
+ )
313
+ resolved_input = input_name or session.get_inputs()[0].name
314
+ resolved_output = output_name or session.get_outputs()[0].name
315
+ return cls(
316
+ session=session,
317
+ input_name=resolved_input,
318
+ output_name=resolved_output,
319
+ preprocessor=preprocessor,
320
+ embedding_dim=embedding_dim,
321
+ )
322
+
323
+ def embed(self, texts: Iterable[str]) -> np.ndarray:
324
+ """Embed a batch of text rows, returning a ``(n, embedding_dim)`` float32 array.
325
+
326
+ ``preprocessor`` may return, per row, either:
327
+
328
+ - a plain ``(feature_dim,)`` array -- fed as the single named input
329
+ ``self.input_name`` (the synthetic fixture / hashing-vectorizer
330
+ shape), or
331
+ - a ``{input_name: (feature_dim,) array}`` mapping -- each named
332
+ input is stacked across the batch and fed to the session under
333
+ its own name (the real multi-input sentence-transformer shape,
334
+ e.g. ``input_ids``/``attention_mask``/``token_type_ids``).
335
+
336
+ Mixing the two shapes across rows in the same call is not
337
+ supported; ``preprocessor``'s return type is assumed to be
338
+ consistent for a given :class:`EmbeddingModel`.
339
+ """
340
+ rows = list(texts)
341
+ if not rows:
342
+ return np.zeros((0, self.embedding_dim), dtype=np.float32)
343
+ raw_features = [self.preprocessor(text) for text in rows]
344
+ if isinstance(raw_features[0], Mapping):
345
+ # Multi-input ONNX graph: each preprocessed row is a dict of
346
+ # named arrays (e.g. input_ids/attention_mask/token_type_ids)
347
+ # rather than a single array for self.input_name. Stack each
348
+ # named input across the batch and feed the session all of them
349
+ # at once, by name -- not just self.input_name.
350
+ input_names = raw_features[0].keys()
351
+ feed = {
352
+ name: np.stack([row[name] for row in raw_features]) for name in input_names # type: ignore[index]
353
+ }
354
+ (output,) = self.session.run([self.output_name], feed)
355
+ else:
356
+ features = np.stack(raw_features).astype(np.float32)
357
+ (output,) = self.session.run([self.output_name], {self.input_name: features})
358
+ return np.asarray(output, dtype=np.float32)
359
+
360
+
361
+ def build_synthetic_embedding_model(
362
+ *,
363
+ cache_dir: str | Path | None = None,
364
+ vocab_dim: int = 128,
365
+ embedding_dim: int = 32,
366
+ seed: int = 0,
367
+ ) -> EmbeddingModel:
368
+ """Build (or reuse a cached) synthetic ONNX embedding model, ready to use.
369
+
370
+ Convenience wrapper around :func:`build_synthetic_embedding_onnx` +
371
+ :func:`hashing_bag_of_words_vectorizer` + :meth:`EmbeddingModel.from_onnx_file`
372
+ so tests and the example call one function instead of duplicating this
373
+ three-step setup. Fully hermetic: no network access, writes a small
374
+ (a few KB) ``.onnx`` file to a temp/cache directory.
375
+ """
376
+ cache_dir_path = Path(cache_dir) if cache_dir is not None else Path(tempfile.gettempdir())
377
+ onnx_path = cache_dir_path / f"lazyclean_synthetic_v{vocab_dim}x{embedding_dim}_seed{seed}.onnx"
378
+ if not onnx_path.exists():
379
+ build_synthetic_embedding_onnx(
380
+ onnx_path, vocab_dim=vocab_dim, embedding_dim=embedding_dim, seed=seed
381
+ )
382
+ preprocessor = hashing_bag_of_words_vectorizer(vocab_dim=vocab_dim)
383
+ return EmbeddingModel.from_onnx_file(
384
+ onnx_path, preprocessor=preprocessor, embedding_dim=embedding_dim
385
+ )
386
+
387
+
388
+ def download_recommended_model(
389
+ *,
390
+ cache_dir: str | Path | None = None,
391
+ accept_restricted_licenses: bool = False,
392
+ ) -> Path:
393
+ """Lazily download the Tier-1 recommended production checkpoint (optional).
394
+
395
+ This is the documented production wiring path -- **never called by
396
+ tests, the example, or any import-time code in this package.** It
397
+ requires network access, which is deliberately not a hard requirement
398
+ for installing or testing this package (per CLAUDE.md's local-only
399
+ constraint: any model-download path must be optional/lazy).
400
+
401
+ Looks the checkpoint up in :data:`MODEL_ALLOWLIST` first (demonstrating
402
+ the shared lazycore.licensing.Allowlist usage pattern) before touching
403
+ the network -- this call would raise ``RestrictedLicenseNotAcceptedError``
404
+ for a Tier 2 model without the opt-in flag, though the currently
405
+ registered recommended model is Tier 1 and does not require it.
406
+
407
+ After downloading, wire the result into :meth:`EmbeddingModel.from_onnx_file`
408
+ together with a real tokenizer (see README) -- this function only
409
+ fetches and caches the ``.onnx`` graph itself.
410
+
411
+ The download is written to a temporary file in ``cache_dir`` first and
412
+ atomically renamed into place only once it completes successfully. If
413
+ the download is interrupted (network error, process kill, etc.) the
414
+ temporary file is removed and ``dest`` is left untouched, so a later
415
+ call never mistakes a truncated/corrupt partial download for a valid
416
+ cached model just because a file already exists at ``dest``. The
417
+ fetched URL is pinned to a specific immutable commit of the upstream
418
+ repo (see ``_RECOMMENDED_MODEL_REVISION``), not a mutable branch ref,
419
+ so repeated calls (and calls across machines) always fetch the exact
420
+ same, previously-reviewed artifact.
421
+ """
422
+ MODEL_ALLOWLIST.check(
423
+ RECOMMENDED_MODEL_NAME, accept_restricted_licenses=accept_restricted_licenses
424
+ )
425
+ cache_dir_path = Path(cache_dir) if cache_dir is not None else Path.home() / ".cache" / "benchcraft" / "lazyclean"
426
+ cache_dir_path.mkdir(parents=True, exist_ok=True)
427
+ dest = cache_dir_path / "all-MiniLM-L6-v2.onnx"
428
+ if dest.exists():
429
+ return dest
430
+
431
+ import urllib.request
432
+
433
+ fd, tmp_name = tempfile.mkstemp(dir=cache_dir_path, suffix=".onnx.part")
434
+ tmp_path = Path(tmp_name)
435
+ try:
436
+ os.close(fd)
437
+ urllib.request.urlretrieve(_RECOMMENDED_MODEL_ONNX_URL, tmp_path) # noqa: S310
438
+ tmp_path.replace(dest) # atomic on the same filesystem
439
+ except BaseException:
440
+ tmp_path.unlink(missing_ok=True)
441
+ raise
442
+ return dest