eval-toolkit 0.27.1__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,1403 @@
1
+ """Text deduplication: pluggable similarity strategies + near-dup orchestrators.
2
+
3
+ Pure helpers:
4
+
5
+ - :func:`sha256_text` — canonical SHA-256 of (optionally normalized) text
6
+ - :func:`normalize_text_for_dedup` — Unicode/whitespace normalization
7
+
8
+ Similarity strategies (Protocol + reference implementations):
9
+
10
+ - :class:`SimilarityStrategy` — Protocol; ``pairs_within`` + ``pairs_across``
11
+ - :class:`TfidfCosineStrategy` — default lexical near-dedup (TF-IDF + cosine).
12
+ Recommended default. Scales to 100K+ texts (sklearn sparse + ANN k-NN).
13
+ - :class:`ExactNormalizedHashStrategy` — SHA-256-bucket exact-paraphrase dedup.
14
+ O(n) hashing; trivially scales to millions of texts.
15
+ - :class:`EmbeddingCosineStrategy` — cosine on caller-supplied embeddings.
16
+ Scale follows the embedder + sklearn k-NN; recommended for semantic
17
+ near-dedup when lexical signal is insufficient.
18
+ - :class:`JaccardNgramStrategy` — set-based n-gram Jaccard. **Diagnostic /
19
+ small-corpus only**: brute-force pairwise (O(n²) memory + compute). Stalls
20
+ on corpora above ~1K texts. Prefer :class:`TfidfCosineStrategy` or
21
+ :class:`EmbeddingCosineStrategy` (with a MinHash/LSH-backed embedder) at
22
+ any production scale.
23
+
24
+ Orchestrators (strategy-agnostic):
25
+
26
+ - :func:`near_dedup` — forward-scan greedy near-deduplication
27
+ - :func:`cross_dedup` — drop eval rows near-duplicate to any train row
28
+ - :class:`DedupReport` — frozen audit-trail of which rows were dropped and why
29
+
30
+ Notes
31
+ -----
32
+
33
+ **NFC normalization asymmetry across strategies.** Only
34
+ :class:`ExactNormalizedHashStrategy` applies Unicode NFC normalization
35
+ before similarity (via :func:`normalize_text_for_dedup`). The TF-IDF,
36
+ embedding, and Jaccard strategies treat composed and decomposed accents
37
+ as different inputs by default. If your corpus mixes NFC and NFD forms
38
+ (common when concatenating data from different OSes), normalize once at
39
+ load time with :func:`normalize_text_for_dedup` and then call any
40
+ strategy.
41
+
42
+ References
43
+ ----------
44
+ .. [1] Lee, K., et al. "Deduplicating training data makes language models
45
+ better." ACL 2022. (NearDup pipeline; modern authority on
46
+ dedup-and-LM-quality.)
47
+ .. [2] Penedo, G., et al. "The RefinedWeb dataset for Falcon LLM."
48
+ NeurIPS Datasets & Benchmarks, 2023.
49
+ .. [3] Unicode Standard Annex #15 (Unicode Normalization Forms).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import hashlib
55
+ import unicodedata
56
+ from collections.abc import Callable, Sequence
57
+ from dataclasses import dataclass
58
+ from typing import Literal, Protocol, runtime_checkable
59
+
60
+ import numpy as np
61
+ from sklearn.feature_extraction.text import TfidfVectorizer
62
+ from sklearn.neighbors import NearestNeighbors
63
+
64
+ __all__ = [
65
+ "DEFAULT_DEDUP_THRESHOLD",
66
+ "DedupReport",
67
+ "EmbeddingCosineStrategy",
68
+ "ExactNormalizedHashStrategy",
69
+ "JaccardNgramStrategy",
70
+ "MinHashLSHStrategy",
71
+ "SimilarityAuditFinding",
72
+ "SimilarityAuditReport",
73
+ "SimilarityStrategy",
74
+ "TfidfCosineStrategy",
75
+ "audit_source_label_similarity",
76
+ "cross_dedup",
77
+ "cross_dedup_pairs",
78
+ "near_dedup",
79
+ "normalize_text_for_dedup",
80
+ "sha256_text",
81
+ ]
82
+
83
+ DEFAULT_DEDUP_THRESHOLD = 0.9
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class DedupReport:
88
+ """Outcome of a near-dedup pass.
89
+
90
+ Parameters
91
+ ----------
92
+ kept_indices : list[int]
93
+ Sorted positions in the input ``texts`` retained after dedup.
94
+ dropped_pairs : list[tuple[int, int, float]]
95
+ Triples ``(dropped_idx, kept_idx, similarity)`` for audit.
96
+ threshold : float
97
+ Cosine-similarity threshold the dedup pass used.
98
+ n_input : int
99
+ Length of the input list (so kept + dropped invariant is checkable).
100
+
101
+ Examples
102
+ --------
103
+ >>> report = DedupReport(
104
+ ... kept_indices=[0, 2],
105
+ ... dropped_pairs=[(1, 0, 0.95)],
106
+ ... threshold=0.9,
107
+ ... n_input=3,
108
+ ... )
109
+ >>> report.n_kept + report.n_dropped == report.n_input
110
+ True
111
+
112
+ Notes
113
+ -----
114
+ The ``kept_indices`` and the first element of each ``dropped_pairs``
115
+ triple form a disjoint partition of ``range(n_input)``. Callers can use
116
+ this property to reconstruct the dropped-set without re-running dedup.
117
+ """
118
+
119
+ kept_indices: list[int]
120
+ dropped_pairs: list[tuple[int, int, float]]
121
+ threshold: float
122
+ n_input: int
123
+
124
+ @property
125
+ def n_kept(self) -> int:
126
+ """Number of input rows retained after deduplication."""
127
+ return len(self.kept_indices)
128
+
129
+ @property
130
+ def n_dropped(self) -> int:
131
+ """Number of input rows dropped as near-duplicates."""
132
+ return len(self.dropped_pairs)
133
+
134
+
135
+ SimilarityRelation = Literal[
136
+ "unspecified",
137
+ "within_source",
138
+ "cross_source",
139
+ "same_label",
140
+ "cross_label",
141
+ "within_source_same_label",
142
+ "within_source_cross_label",
143
+ "cross_source_same_label",
144
+ "cross_source_cross_label",
145
+ ]
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class SimilarityAuditFinding:
150
+ """One high-similarity pair found during a non-dropping audit."""
151
+
152
+ left_index: int
153
+ right_index: int
154
+ similarity: float
155
+ relation: SimilarityRelation = "unspecified"
156
+ left_source: str | None = None
157
+ right_source: str | None = None
158
+ left_label: object | None = None
159
+ right_label: object | None = None
160
+
161
+ def to_dict(self) -> dict[str, object]:
162
+ """JSON-serializable representation."""
163
+ out: dict[str, object] = {
164
+ "left_index": self.left_index,
165
+ "right_index": self.right_index,
166
+ "similarity": self.similarity,
167
+ "relation": self.relation,
168
+ }
169
+ if self.left_source is not None:
170
+ out["left_source"] = self.left_source
171
+ if self.right_source is not None:
172
+ out["right_source"] = self.right_source
173
+ if self.left_label is not None:
174
+ out["left_label"] = self.left_label
175
+ if self.right_label is not None:
176
+ out["right_label"] = self.right_label
177
+ return out
178
+
179
+
180
+ @dataclass(frozen=True, slots=True)
181
+ class SimilarityAuditReport:
182
+ """Non-dropping source/label-aware similarity audit report."""
183
+
184
+ findings: list[SimilarityAuditFinding]
185
+ threshold: float
186
+ n_input: int
187
+ strategy: str
188
+ k_neighbors: int
189
+
190
+ @property
191
+ def n_findings(self) -> int:
192
+ """Number of high-similarity pairs in the report."""
193
+ return len(self.findings)
194
+
195
+ def to_dict(self) -> dict[str, object]:
196
+ """JSON-serializable representation."""
197
+ return {
198
+ "findings": [finding.to_dict() for finding in self.findings],
199
+ "threshold": self.threshold,
200
+ "n_input": self.n_input,
201
+ "strategy": self.strategy,
202
+ "k_neighbors": self.k_neighbors,
203
+ "n_findings": self.n_findings,
204
+ }
205
+
206
+
207
+ def normalize_text_for_dedup(text: str) -> str:
208
+ """Canonical text normalization for hashing and deduplication.
209
+
210
+ Applies:
211
+
212
+ 1. Unicode NFC normalization (composes accent characters)
213
+ 2. Lowercase
214
+ 3. Collapse runs of whitespace to single spaces
215
+ 4. Strip leading/trailing whitespace
216
+
217
+ Parameters
218
+ ----------
219
+ text : str
220
+
221
+ Returns
222
+ -------
223
+ str
224
+ Normalized text.
225
+
226
+ Raises
227
+ ------
228
+ TypeError
229
+ If ``text`` is not a ``str``.
230
+
231
+ Examples
232
+ --------
233
+ >>> normalize_text_for_dedup("Hello World")
234
+ 'hello world'
235
+ >>> normalize_text_for_dedup(" Foo\\tBar\\n")
236
+ 'foo bar'
237
+ >>> normalize_text_for_dedup(normalize_text_for_dedup("X")) == normalize_text_for_dedup("X")
238
+ True
239
+ """
240
+ if not isinstance(text, str):
241
+ raise TypeError(f"text must be str, got {type(text).__name__}")
242
+ nfc = unicodedata.normalize("NFC", text)
243
+ lowered = nfc.lower()
244
+ collapsed = " ".join(lowered.split())
245
+ return collapsed
246
+
247
+
248
+ def sha256_text(text: str, *, normalize: bool = True) -> str:
249
+ """SHA-256 hex digest of (optionally normalized) text.
250
+
251
+ Parameters
252
+ ----------
253
+ text : str
254
+ normalize : bool, optional
255
+ If True (default), apply :func:`normalize_text_for_dedup` first.
256
+ Set False to hash the raw bytes.
257
+
258
+ Returns
259
+ -------
260
+ str
261
+ 64-character hex digest.
262
+
263
+ Examples
264
+ --------
265
+ >>> sha256_text("Hello World") == sha256_text("hello world")
266
+ True
267
+ >>> len(sha256_text("foo"))
268
+ 64
269
+ >>> sha256_text("foo", normalize=False) != sha256_text("FOO", normalize=False)
270
+ True
271
+
272
+ Raises
273
+ ------
274
+ TypeError
275
+ If ``text`` is not a ``str`` (or, when ``normalize=True``, on the
276
+ re-raised :class:`TypeError` from :func:`normalize_text_for_dedup`).
277
+ """
278
+ if not isinstance(text, str):
279
+ raise TypeError(f"text must be str, got {type(text).__name__}")
280
+ canonical = normalize_text_for_dedup(text) if normalize else text
281
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # SimilarityStrategy Protocol + reference implementations
286
+ # ---------------------------------------------------------------------------
287
+
288
+
289
+ @runtime_checkable
290
+ class SimilarityStrategy(Protocol):
291
+ """Pluggable similarity backend for :func:`near_dedup` / :func:`cross_dedup`.
292
+
293
+ Strategies own vectorization plus nearest-neighbor lookup. Similarities
294
+ are floats; higher values mean more similar. Each strategy chooses its
295
+ own scale (e.g. cosine in ``[-1, 1]``, Jaccard in ``[0, 1]``, hash
296
+ collision in ``{0.0, 1.0}``); threshold semantics live in the orchestrators
297
+ (``near_dedup`` / ``cross_dedup``), not the strategy itself.
298
+
299
+ The shape mirrors the existing :class:`eval_toolkit.harness.Scorer`
300
+ Protocol: structural typing, no ABC inheritance, runtime-checkable so
301
+ ``isinstance(obj, SimilarityStrategy)`` returns True for any object that
302
+ exposes the two methods.
303
+
304
+ Notes
305
+ -----
306
+ Both methods return aligned ``(similarities, indices)`` arrays of shape
307
+ ``(n_query, k_eff)`` where ``k_eff = min(k, n_reference)``. ``indices[i]``
308
+ are positions into the reference list; for ``pairs_within`` the reference
309
+ list IS the input list, so ``indices[i, 0] == i`` is conventional (each
310
+ text is its own most-similar neighbor).
311
+
312
+ See Also
313
+ --------
314
+ eval_toolkit.harness.Scorer : analogous Protocol pattern for predict_proba.
315
+ """
316
+
317
+ def pairs_within(
318
+ self, texts: Sequence[str], k: int
319
+ ) -> tuple[np.ndarray, np.ndarray]: # pragma: no cover
320
+ """k-NN of each text against the same list."""
321
+ ...
322
+
323
+ def pairs_across(
324
+ self,
325
+ query_texts: Sequence[str],
326
+ reference_texts: Sequence[str],
327
+ k: int,
328
+ ) -> tuple[np.ndarray, np.ndarray]: # pragma: no cover
329
+ """k-NN of each query against ``reference_texts``."""
330
+ ...
331
+
332
+
333
+ @dataclass(frozen=True, slots=True)
334
+ class TfidfCosineStrategy:
335
+ """TF-IDF (n-gram) cosine similarity — the default lexical near-dedup.
336
+
337
+ Wraps :class:`sklearn.feature_extraction.text.TfidfVectorizer` plus
338
+ :class:`sklearn.neighbors.NearestNeighbors`. With default parameters,
339
+ behavior is bit-for-bit equivalent to the inline implementation shipped
340
+ in eval-toolkit v0.1.0.
341
+
342
+ Parameters
343
+ ----------
344
+ ngram_range : tuple[int, int], optional
345
+ ``(min_n, max_n)`` n-gram range. Default ``(1, 3)``.
346
+ min_df : int, optional
347
+ Minimum document frequency for a term. Default ``1``.
348
+ lowercase : bool, optional
349
+ Lowercase before vectorizing. Default ``True``.
350
+
351
+ Examples
352
+ --------
353
+ >>> strat = TfidfCosineStrategy()
354
+ >>> sims, idx = strat.pairs_within(
355
+ ... ["the quick fox", "the quick fox!", "lorem ipsum"], k=2
356
+ ... )
357
+ >>> sims.shape, idx.shape
358
+ ((3, 2), (3, 2))
359
+ >>> bool(idx[0, 0] == 0) # self at slot 0
360
+ True
361
+
362
+ Notes
363
+ -----
364
+ TF-IDF cosine is a fast lexical signal — it will miss paraphrase
365
+ duplicates that share no n-grams. For semantic-paraphrase dedup, see
366
+ :class:`EmbeddingCosineStrategy`.
367
+
368
+ Scaling: scales to ~100K+ texts on a single machine via sklearn's
369
+ sparse vectorizer + approximate nearest neighbors. This is the
370
+ recommended default for any non-trivial corpus.
371
+ """
372
+
373
+ ngram_range: tuple[int, int] = (1, 3)
374
+ min_df: int = 1
375
+ lowercase: bool = True
376
+
377
+ def _vectorizer(self) -> TfidfVectorizer:
378
+ """Construct a fresh TfidfVectorizer with the strategy's config."""
379
+ return TfidfVectorizer(
380
+ ngram_range=self.ngram_range,
381
+ min_df=self.min_df,
382
+ lowercase=self.lowercase,
383
+ )
384
+
385
+ def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
386
+ """k-NN within ``texts`` via TF-IDF + cosine NearestNeighbors."""
387
+ n = len(texts)
388
+ if n == 0:
389
+ return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
390
+ vec = self._vectorizer()
391
+ tfidf = vec.fit_transform(list(texts))
392
+ k_eff = min(k, n)
393
+ nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(tfidf)
394
+ distances, indices = nn.kneighbors(tfidf)
395
+ return 1.0 - distances, indices.astype(np.int64)
396
+
397
+ def pairs_across(
398
+ self,
399
+ query_texts: Sequence[str],
400
+ reference_texts: Sequence[str],
401
+ k: int,
402
+ ) -> tuple[np.ndarray, np.ndarray]:
403
+ """k-NN of ``query_texts`` against ``reference_texts``."""
404
+ if not reference_texts or not query_texts:
405
+ return (
406
+ np.zeros((len(query_texts), 0), dtype=np.float64),
407
+ np.zeros((len(query_texts), 0), dtype=np.int64),
408
+ )
409
+ vec = self._vectorizer()
410
+ # Fit on union so vocabulary covers both sides; matches v0.1.0 behavior.
411
+ vec.fit(list(reference_texts) + list(query_texts))
412
+ tfidf_ref = vec.transform(list(reference_texts))
413
+ tfidf_query = vec.transform(list(query_texts))
414
+ k_eff = min(k, len(reference_texts))
415
+ nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(tfidf_ref)
416
+ distances, indices = nn.kneighbors(tfidf_query)
417
+ return 1.0 - distances, indices.astype(np.int64)
418
+
419
+
420
+ @dataclass(frozen=True, slots=True)
421
+ class ExactNormalizedHashStrategy:
422
+ """SHA-256 hash-bucket dedup; similarities are exactly ``{0.0, 1.0}``.
423
+
424
+ Reuses :func:`normalize_text_for_dedup` + :func:`sha256_text`. Two texts
425
+ are similar iff they share a hash bucket — useful when the operational
426
+ sense of "leakage" is exact paraphrase (after Unicode/whitespace/case
427
+ normalization) rather than near-paraphrase.
428
+
429
+ Parameters
430
+ ----------
431
+ normalize : bool, optional
432
+ If ``True`` (default), apply :func:`normalize_text_for_dedup` before
433
+ hashing. Set ``False`` to bucket on raw bytes only.
434
+
435
+ Examples
436
+ --------
437
+ >>> strat = ExactNormalizedHashStrategy()
438
+ >>> sims, idx = strat.pairs_within(["foo", "FOO", "bar"], k=2)
439
+ >>> # Texts 0 and 1 collide after lowercasing; text 2 isolated.
440
+ >>> bool(sims[0, 0] == 1.0 and idx[0, 0] == 0) # self at slot 0
441
+ True
442
+ >>> bool(sims[0, 1] == 1.0 and idx[0, 1] == 1) # collision with text 1
443
+ True
444
+ >>> bool(sims[2, 1] == 0.0) # text 2 has no collision in the corpus
445
+ True
446
+
447
+ Notes
448
+ -----
449
+ Threshold ``t`` semantics for this strategy: any ``t`` in ``(0, 1]``
450
+ catches all collisions (since collisions are sim=1.0); a threshold
451
+ ``> 1.0`` catches nothing. The default ``DEFAULT_DEDUP_THRESHOLD = 0.9``
452
+ works as expected.
453
+
454
+ Scaling: O(n) hashing + O(n) bucket lookup — trivially scales to
455
+ millions of texts. Use this strategy when "leakage" means *exact match
456
+ after normalization*, not paraphrase. For lexical near-paraphrase use
457
+ :class:`TfidfCosineStrategy`; for semantic near-paraphrase use
458
+ :class:`EmbeddingCosineStrategy`.
459
+ """
460
+
461
+ normalize: bool = True
462
+
463
+ def _hash(self, text: str) -> str:
464
+ """SHA-256 of (optionally normalized) text."""
465
+ return sha256_text(text, normalize=self.normalize)
466
+
467
+ @staticmethod
468
+ def _build_neighbors(
469
+ n: int,
470
+ k_eff: int,
471
+ bucket_for_query: list[list[int]],
472
+ ref_hashes: Sequence[str],
473
+ query_hashes: Sequence[str],
474
+ n_ref: int,
475
+ ) -> tuple[np.ndarray, np.ndarray]:
476
+ """Pack each query's collision list (then arbitrary fillers) into (n, k)."""
477
+ sims = np.zeros((n, k_eff), dtype=np.float64)
478
+ indices = np.zeros((n, k_eff), dtype=np.int64)
479
+ ref_index_set = set(range(n_ref))
480
+ for i in range(n):
481
+ collisions = bucket_for_query[i]
482
+ non_collisions = sorted(ref_index_set - set(collisions))
483
+ ranked = collisions + non_collisions
484
+ chosen = ranked[:k_eff]
485
+ for slot, j in enumerate(chosen):
486
+ indices[i, slot] = j
487
+ sims[i, slot] = 1.0 if ref_hashes[j] == query_hashes[i] else 0.0
488
+ return sims, indices
489
+
490
+ def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
491
+ n = len(texts)
492
+ if n == 0:
493
+ return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
494
+ hashes = [self._hash(t) for t in texts]
495
+ bucket: dict[str, list[int]] = {}
496
+ for i, h in enumerate(hashes):
497
+ bucket.setdefault(h, []).append(i)
498
+ # Self at slot 0; then other collisions; then arbitrary fillers.
499
+ bucket_for_query = [[i] + [j for j in bucket[hashes[i]] if j != i] for i in range(n)]
500
+ k_eff = min(k, n)
501
+ return self._build_neighbors(n, k_eff, bucket_for_query, hashes, hashes, n)
502
+
503
+ def pairs_across(
504
+ self,
505
+ query_texts: Sequence[str],
506
+ reference_texts: Sequence[str],
507
+ k: int,
508
+ ) -> tuple[np.ndarray, np.ndarray]:
509
+ n_q, n_r = len(query_texts), len(reference_texts)
510
+ if n_q == 0 or n_r == 0:
511
+ return (
512
+ np.zeros((n_q, 0), dtype=np.float64),
513
+ np.zeros((n_q, 0), dtype=np.int64),
514
+ )
515
+ ref_hashes = [self._hash(t) for t in reference_texts]
516
+ q_hashes = [self._hash(t) for t in query_texts]
517
+ ref_bucket: dict[str, list[int]] = {}
518
+ for i, h in enumerate(ref_hashes):
519
+ ref_bucket.setdefault(h, []).append(i)
520
+ bucket_for_query = [list(ref_bucket.get(qh, [])) for qh in q_hashes]
521
+ k_eff = min(k, n_r)
522
+ return self._build_neighbors(n_q, k_eff, bucket_for_query, ref_hashes, q_hashes, n_r)
523
+
524
+
525
+ @dataclass(frozen=True, slots=True)
526
+ class EmbeddingCosineStrategy:
527
+ """Cosine similarity on caller-supplied embeddings.
528
+
529
+ The toolkit owns cosine + k-NN; the caller owns the embedder. This keeps
530
+ the toolkit dep-free of any specific embedding library
531
+ (sentence-transformers, OpenAI, local model, etc.) while still offering
532
+ a turnkey strategy class.
533
+
534
+ Parameters
535
+ ----------
536
+ embedder : Callable[[Sequence[str]], np.ndarray]
537
+ Maps a sequence of texts to a 2-D array of shape ``(n, d)``. Caller
538
+ is responsible for batching, GPU placement, model loading, etc.
539
+
540
+ Examples
541
+ --------
542
+ >>> import numpy as np
543
+ >>> def stub_embedder(ts):
544
+ ... # One-hot per text index → behaves like exact-match on identity.
545
+ ... return np.eye(len(ts))
546
+ >>> strat = EmbeddingCosineStrategy(stub_embedder)
547
+ >>> sims, idx = strat.pairs_within(["a", "b", "c"], k=3)
548
+ >>> bool(sims.shape == (3, 3) and idx.shape == (3, 3))
549
+ True
550
+ >>> bool(idx[0, 0] == 0) # self at slot 0
551
+ True
552
+
553
+ Notes
554
+ -----
555
+ The embedder is called once per ``pairs_*`` invocation. Persistent
556
+ embedding caches are out of scope — callers should wrap their embedder
557
+ in any cache they need.
558
+
559
+ Scaling: dominated by the embedder. With sentence-transformers on GPU,
560
+ 100K+ texts is routine; with OpenAI / remote-API embedders, throughput
561
+ is API-limited but k-NN itself remains fast (sklearn cosine NN). This
562
+ is the recommended strategy whenever lexical TF-IDF signal is
563
+ insufficient (paraphrase, multilingual, semantic-near-duplicate).
564
+ """
565
+
566
+ embedder: Callable[[Sequence[str]], np.ndarray]
567
+
568
+ def _embed(self, texts: Sequence[str], name: str) -> np.ndarray:
569
+ """Coerce embedder output to a 2-D float64 ndarray + validate shape."""
570
+ emb = np.asarray(self.embedder(texts), dtype=np.float64)
571
+ if emb.ndim != 2:
572
+ raise ValueError(f"embedder must return 2-D array; {name} got shape {emb.shape}")
573
+ if emb.shape[0] != len(texts):
574
+ raise ValueError(
575
+ f"embedder must return one row per text; {name} got shape "
576
+ f"{emb.shape} for {len(texts)} texts"
577
+ )
578
+ return emb
579
+
580
+ def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
581
+ n = len(texts)
582
+ if n == 0:
583
+ return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
584
+ emb = self._embed(texts, "texts")
585
+ k_eff = min(k, n)
586
+ nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(emb)
587
+ distances, indices = nn.kneighbors(emb)
588
+ return 1.0 - distances, indices.astype(np.int64)
589
+
590
+ def pairs_across(
591
+ self,
592
+ query_texts: Sequence[str],
593
+ reference_texts: Sequence[str],
594
+ k: int,
595
+ ) -> tuple[np.ndarray, np.ndarray]:
596
+ """Top-k cosine-similar reference rows per query row.
597
+
598
+ Raises
599
+ ------
600
+ ValueError
601
+ If the embedder returns inconsistent feature dimensions for
602
+ ``reference_texts`` vs ``query_texts`` (would silently
603
+ mis-align cosine; better to fail loudly).
604
+ """
605
+ n_q, n_r = len(query_texts), len(reference_texts)
606
+ if n_q == 0 or n_r == 0:
607
+ return (
608
+ np.zeros((n_q, 0), dtype=np.float64),
609
+ np.zeros((n_q, 0), dtype=np.int64),
610
+ )
611
+ ref_emb = self._embed(reference_texts, "reference_texts")
612
+ query_emb = self._embed(query_texts, "query_texts")
613
+ # Cross-call dimension consistency — buggy embedders that return
614
+ # different `d` for query vs reference would silently mis-align cosine.
615
+ if ref_emb.shape[1] != query_emb.shape[1]:
616
+ raise ValueError(
617
+ f"embedder returned inconsistent feature dimensions: "
618
+ f"reference_texts has d={ref_emb.shape[1]}, "
619
+ f"query_texts has d={query_emb.shape[1]}"
620
+ )
621
+ k_eff = min(k, n_r)
622
+ nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(ref_emb)
623
+ distances, indices = nn.kneighbors(query_emb)
624
+ return 1.0 - distances, indices.astype(np.int64)
625
+
626
+
627
+ @dataclass(frozen=True, slots=True)
628
+ class JaccardNgramStrategy:
629
+ """Set-based n-gram Jaccard similarity — **diagnostic / small-corpus only**.
630
+
631
+ .. warning::
632
+
633
+ Brute-force pairwise — O(n²) in the corpus size for both memory and
634
+ compute. Empirical scaling on a single core: ~1K texts in under a
635
+ second; ~10K texts ≈ tens of seconds; ~100K texts will exhaust
636
+ memory and is effectively unusable. **Do not use this strategy at
637
+ production scale.** Prefer :class:`TfidfCosineStrategy` (default,
638
+ sparse + ANN k-NN, scales to 100K+) or :class:`EmbeddingCosineStrategy`
639
+ backed by a MinHash/LSH or sentence-transformer embedder.
640
+
641
+ When this strategy is the right tool:
642
+
643
+ - **Tiny diagnostic corpora** (< ~500 texts) where you want a clean
644
+ mathematical match-counting interpretation of similarity.
645
+ - **Token-order-invariant fingerprints** where lexical TF-IDF cosine
646
+ over-weights position (e.g., SQL fragments, CLI-flag strings,
647
+ shell-command normalization, JSON-key bag-of-words).
648
+ - **Reference / sanity-check** implementation against which you want to
649
+ validate a faster MinHash/LSH approximation.
650
+
651
+ Parameters
652
+ ----------
653
+ n : int, optional
654
+ N-gram size. Default ``3``. Must be ``≥ 1``.
655
+ analyzer : {'char', 'word'}, optional
656
+ N-gram unit. Default ``'char'``.
657
+
658
+ Examples
659
+ --------
660
+ >>> strat = JaccardNgramStrategy(n=2, analyzer='char')
661
+ >>> # bigrams("abc") = {ab, bc}; bigrams("abd") = {ab, bd};
662
+ >>> # ∩ = {ab}; ∪ = {ab, bc, bd} → J = 1/3.
663
+ >>> sims, idx = strat.pairs_within(["abc", "abd"], k=2)
664
+ >>> bool(abs(float(sims[0, 1]) - 1 / 3) < 1e-9)
665
+ True
666
+
667
+ Notes
668
+ -----
669
+ Jaccard on *sets* of n-grams (order-invariant) gives a strict-match
670
+ similarity in ``[0, 1]``: identical n-gram sets map to 1.0, disjoint
671
+ n-gram sets to 0.0. For approximate Jaccard at production scale, see
672
+ MinHash + LSH (Broder 1997, Indyk & Motwani 1998); the toolkit does not
673
+ ship a MinHash strategy in v0.2 — wrap your preferred MinHash library in
674
+ :class:`EmbeddingCosineStrategy` if you need it.
675
+
676
+ References
677
+ ----------
678
+ .. [1] Broder, A. "On the resemblance and containment of documents."
679
+ Compression and Complexity of Sequences, 1997.
680
+ .. [2] Indyk, P. & Motwani, R. "Approximate nearest neighbors: Towards
681
+ removing the curse of dimensionality." STOC 1998.
682
+ """
683
+
684
+ n: int = 3
685
+ analyzer: Literal["char", "word"] = "char"
686
+
687
+ def __post_init__(self) -> None:
688
+ """Validate constructor arguments (frozen dataclass invariants)."""
689
+ if self.n < 1:
690
+ raise ValueError(f"n must be >= 1, got {self.n}")
691
+ if self.analyzer not in ("char", "word"):
692
+ raise ValueError(f"analyzer must be 'char' or 'word', got {self.analyzer!r}")
693
+
694
+ def _ngrams(self, text: str) -> set[str]:
695
+ """N-gram set for ``text`` per the configured analyzer."""
696
+ if self.analyzer == "char":
697
+ if len(text) < self.n:
698
+ return {text} if text else set()
699
+ return {text[i : i + self.n] for i in range(len(text) - self.n + 1)}
700
+ tokens = text.split()
701
+ if len(tokens) < self.n:
702
+ return {" ".join(tokens)} if tokens else set()
703
+ return {" ".join(tokens[i : i + self.n]) for i in range(len(tokens) - self.n + 1)}
704
+
705
+ @staticmethod
706
+ def _jaccard(a: set[str], b: set[str]) -> float:
707
+ """Jaccard similarity ``|a ∩ b| / |a ∪ b|``; both empty → 1.0."""
708
+ if not a and not b:
709
+ return 1.0
710
+ union = a | b
711
+ if not union:
712
+ return 0.0
713
+ return len(a & b) / len(union)
714
+
715
+ def _pairwise(
716
+ self,
717
+ queries: Sequence[set[str]],
718
+ references: Sequence[set[str]],
719
+ ) -> np.ndarray:
720
+ """Brute-force Jaccard matrix of shape ``(len(queries), len(references))``."""
721
+ m = np.zeros((len(queries), len(references)), dtype=np.float64)
722
+ for i, qs in enumerate(queries):
723
+ for j, rs in enumerate(references):
724
+ m[i, j] = self._jaccard(qs, rs)
725
+ return m
726
+
727
+ @staticmethod
728
+ def _topk(matrix: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
729
+ """Top-k by descending similarity per row; stable on ties."""
730
+ order = np.argsort(-matrix, axis=1, kind="stable")[:, :k]
731
+ sims = np.take_along_axis(matrix, order, axis=1)
732
+ return sims.astype(np.float64), order.astype(np.int64)
733
+
734
+ def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
735
+ n = len(texts)
736
+ if n == 0:
737
+ return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
738
+ ngs = [self._ngrams(t) for t in texts]
739
+ k_eff = min(k, n)
740
+ return self._topk(self._pairwise(ngs, ngs), k_eff)
741
+
742
+ def pairs_across(
743
+ self,
744
+ query_texts: Sequence[str],
745
+ reference_texts: Sequence[str],
746
+ k: int,
747
+ ) -> tuple[np.ndarray, np.ndarray]:
748
+ n_q, n_r = len(query_texts), len(reference_texts)
749
+ if n_q == 0 or n_r == 0:
750
+ return (
751
+ np.zeros((n_q, 0), dtype=np.float64),
752
+ np.zeros((n_q, 0), dtype=np.int64),
753
+ )
754
+ q_ngs = [self._ngrams(t) for t in query_texts]
755
+ r_ngs = [self._ngrams(t) for t in reference_texts]
756
+ k_eff = min(k, n_r)
757
+ return self._topk(self._pairwise(q_ngs, r_ngs), k_eff)
758
+
759
+
760
+ # ---------------------------------------------------------------------------
761
+ # MinHash + LSH: production-scale approximate Jaccard
762
+ # ---------------------------------------------------------------------------
763
+
764
+
765
+ # Mersenne prime > 2**32 — used for the universal hash family in MinHash.
766
+ _MINHASH_PRIME: int = (1 << 61) - 1
767
+ _MINHASH_MAX_HASH: int = (1 << 32) - 1
768
+
769
+
770
+ @dataclass(frozen=True, slots=True)
771
+ class MinHashLSHStrategy:
772
+ r"""Approximate Jaccard via MinHash + LSH banding (Broder 1997, Indyk-Motwani 1998).
773
+
774
+ Production-scale alternative to :class:`JaccardNgramStrategy`. Computes
775
+ MinHash signatures of length ``num_perm`` for each text using a
776
+ universal hash family, then Locality-Sensitive Hashing (LSH) with
777
+ ``bands × rows = num_perm`` partitions the signature space so any two
778
+ texts with high Jaccard similarity probably collide in at least one
779
+ band. For each query, we expand its candidate set from the LSH
780
+ buckets, compute exact Jaccard on the surviving candidates, and
781
+ return the top-``k`` by similarity.
782
+
783
+ .. note::
784
+
785
+ This is an *approximate* Jaccard. Two texts with true Jaccard ≥
786
+ threshold are *probably* (not certainly) discovered as
787
+ candidates; the false-negative probability decreases with the
788
+ ``num_perm`` × bands tuning. See Indyk & Motwani 1998 for the
789
+ analysis of the LSH band-curve.
790
+
791
+ Parameters
792
+ ----------
793
+ n : int, optional
794
+ Character n-gram size for shingling. Default ``3``.
795
+ num_perm : int, optional
796
+ Number of MinHash permutations / signature length. Default
797
+ ``128``. Larger num_perm → more accurate Jaccard estimate, more
798
+ compute. ``num_perm`` must equal ``bands × rows_per_band``.
799
+ bands : int, optional
800
+ Number of LSH bands. Default ``16``. With default ``num_perm=128``
801
+ this gives ``rows_per_band = 8``. The LSH probability of two
802
+ texts with true Jaccard :math:`s` colliding in at least one band
803
+ is :math:`1 - (1 - s^r)^b`. Tune ``(bands, rows_per_band)`` to
804
+ the threshold you care about; e.g. ``(20, 5)`` flips around
805
+ :math:`s = 0.5`, ``(16, 8)`` flips around :math:`s ≈ 0.74`.
806
+ seed : int, optional
807
+ Deterministic seed for the universal-hash coefficients. Default
808
+ ``42``.
809
+
810
+ Examples
811
+ --------
812
+ >>> import numpy as np
813
+ >>> strat = MinHashLSHStrategy(n=2, num_perm=64, bands=16, seed=0)
814
+ >>> texts = ["the quick brown fox", "the quick brown fox!", "lorem ipsum"]
815
+ >>> sims, idx = strat.pairs_within(texts, k=2)
816
+ >>> sims.shape, idx.shape
817
+ ((3, 2), (3, 2))
818
+
819
+ Notes
820
+ -----
821
+ Scaling: ``num_perm × n_texts`` MinHash work for signatures, then
822
+ LSH bucketing is O(bands · n_texts) hashing + O(candidates per query)
823
+ Jaccard recomputation. For ``n_texts = 100K`` with default params,
824
+ this is dramatically faster than :class:`JaccardNgramStrategy`'s
825
+ O(n²) brute-force.
826
+
827
+ The "fillers" returned for queries with fewer than ``k`` candidates
828
+ are filled in with arbitrary indices and similarity ``0.0``;
829
+ downstream callers (``near_dedup``, ``cross_dedup``) only act on
830
+ similarities ``≥ threshold`` so the fillers are correctly ignored.
831
+
832
+ References
833
+ ----------
834
+ .. [1] Broder, A. "On the resemblance and containment of documents."
835
+ Compression and Complexity of Sequences, 1997.
836
+ .. [2] Indyk, P. & Motwani, R. "Approximate nearest neighbors:
837
+ Towards removing the curse of dimensionality." STOC 1998.
838
+ """
839
+
840
+ n: int = 3
841
+ num_perm: int = 128
842
+ bands: int = 16
843
+ seed: int = 42
844
+
845
+ def __post_init__(self) -> None:
846
+ """Validate constructor arguments."""
847
+ if self.n < 1:
848
+ raise ValueError(f"n must be ≥ 1, got {self.n}")
849
+ if self.num_perm < 8:
850
+ raise ValueError(f"num_perm must be ≥ 8, got {self.num_perm}")
851
+ if self.bands < 1 or self.bands > self.num_perm:
852
+ raise ValueError(f"bands must be in [1, num_perm={self.num_perm}], got {self.bands}")
853
+ if self.num_perm % self.bands != 0:
854
+ raise ValueError(
855
+ f"num_perm ({self.num_perm}) must be divisible by bands "
856
+ f"({self.bands}) for evenly-sized rows"
857
+ )
858
+
859
+ @property
860
+ def rows_per_band(self) -> int:
861
+ """Rows per LSH band; equals ``num_perm // bands``."""
862
+ return self.num_perm // self.bands
863
+
864
+ def _hash_coefs(self) -> tuple[np.ndarray, np.ndarray]:
865
+ """Build (a, b) coefficients for the universal hash family.
866
+
867
+ The hash is :math:`h_i(x) = ((a_i x + b_i) \\mod p) \\mod 2^{32}`,
868
+ a 4-universal hash family per Carter & Wegman 1979.
869
+ """
870
+ rng = np.random.default_rng(self.seed)
871
+ a = rng.integers(1, _MINHASH_PRIME, size=self.num_perm, dtype=np.int64)
872
+ b = rng.integers(0, _MINHASH_PRIME, size=self.num_perm, dtype=np.int64)
873
+ return a, b
874
+
875
+ def _shingles(self, text: str) -> set[str]:
876
+ """Char n-gram set for ``text`` (matches JaccardNgramStrategy.char-mode)."""
877
+ if len(text) < self.n:
878
+ return {text} if text else set()
879
+ return {text[i : i + self.n] for i in range(len(text) - self.n + 1)}
880
+
881
+ def _signature(self, shingle_set: set[str], a: np.ndarray, b: np.ndarray) -> np.ndarray:
882
+ """MinHash signature of length ``num_perm`` for one shingle set."""
883
+ if not shingle_set:
884
+ return np.full(self.num_perm, _MINHASH_MAX_HASH, dtype=np.int64)
885
+ # Hash each shingle to a 32-bit integer (deterministic via SHA-256).
886
+ shingle_hashes = np.fromiter(
887
+ (
888
+ int.from_bytes(
889
+ hashlib.sha256(s.encode("utf-8")).digest()[:4],
890
+ "big",
891
+ )
892
+ for s in shingle_set
893
+ ),
894
+ dtype=np.int64,
895
+ count=len(shingle_set),
896
+ )
897
+ # For each of the num_perm permutations, take the min over the shingle hashes.
898
+ # h_i(x) = ((a_i * x + b_i) mod prime) mod 2^32 — universal hash.
899
+ permuted = (
900
+ (np.outer(shingle_hashes, a) + b[np.newaxis, :])
901
+ % _MINHASH_PRIME
902
+ % (_MINHASH_MAX_HASH + 1)
903
+ )
904
+ sig: np.ndarray = permuted.min(axis=0).astype(np.int64)
905
+ return sig
906
+
907
+ def _signatures_for(self, texts: Sequence[str]) -> np.ndarray:
908
+ """Stack of MinHash signatures of shape ``(len(texts), num_perm)``."""
909
+ a, b = self._hash_coefs()
910
+ n = len(texts)
911
+ sigs = np.zeros((n, self.num_perm), dtype=np.int64)
912
+ for i, t in enumerate(texts):
913
+ sigs[i] = self._signature(self._shingles(t), a, b)
914
+ return sigs
915
+
916
+ def _build_lsh_index(self, sigs: np.ndarray) -> list[dict[bytes, list[int]]]:
917
+ """Bucket signatures into ``self.bands`` band-keyed dicts."""
918
+ rows = self.rows_per_band
919
+ indices: list[dict[bytes, list[int]]] = [{} for _ in range(self.bands)]
920
+ for i in range(sigs.shape[0]):
921
+ for band in range(self.bands):
922
+ start = band * rows
923
+ key = sigs[i, start : start + rows].tobytes()
924
+ indices[band].setdefault(key, []).append(i)
925
+ return indices
926
+
927
+ def _query_lsh(
928
+ self,
929
+ sigs_query: np.ndarray,
930
+ sigs_ref: np.ndarray,
931
+ ref_index: list[dict[bytes, list[int]]],
932
+ ) -> list[set[int]]:
933
+ """Per-query candidate set: ref indices sharing ≥ 1 band hash."""
934
+ rows = self.rows_per_band
935
+ candidates: list[set[int]] = [set() for _ in range(sigs_query.shape[0])]
936
+ for i in range(sigs_query.shape[0]):
937
+ for band in range(self.bands):
938
+ start = band * rows
939
+ key = sigs_query[i, start : start + rows].tobytes()
940
+ if key in ref_index[band]:
941
+ candidates[i].update(ref_index[band][key])
942
+ return candidates
943
+
944
+ @staticmethod
945
+ def _exact_jaccard(a: set[str], b: set[str]) -> float:
946
+ """Standard Jaccard; matches :meth:`JaccardNgramStrategy._jaccard`."""
947
+ if not a and not b:
948
+ return 1.0
949
+ union = a | b
950
+ if not union:
951
+ return 0.0
952
+ return len(a & b) / len(union)
953
+
954
+ def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
955
+ n = len(texts)
956
+ if n == 0:
957
+ return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
958
+ sigs = self._signatures_for(texts)
959
+ index = self._build_lsh_index(sigs)
960
+ shingles_per_text = [self._shingles(t) for t in texts]
961
+ return self._compute_topk(
962
+ list(range(n)),
963
+ shingles_per_text,
964
+ shingles_per_text,
965
+ self._query_lsh(sigs, sigs, index),
966
+ k_eff=min(k, n),
967
+ n_ref=n,
968
+ include_self=True,
969
+ )
970
+
971
+ def pairs_across(
972
+ self,
973
+ query_texts: Sequence[str],
974
+ reference_texts: Sequence[str],
975
+ k: int,
976
+ ) -> tuple[np.ndarray, np.ndarray]:
977
+ n_q, n_r = len(query_texts), len(reference_texts)
978
+ if n_q == 0 or n_r == 0:
979
+ return (
980
+ np.zeros((n_q, 0), dtype=np.float64),
981
+ np.zeros((n_q, 0), dtype=np.int64),
982
+ )
983
+ sigs_ref = self._signatures_for(reference_texts)
984
+ sigs_query = self._signatures_for(query_texts)
985
+ index = self._build_lsh_index(sigs_ref)
986
+ ref_shingles = [self._shingles(t) for t in reference_texts]
987
+ query_shingles = [self._shingles(t) for t in query_texts]
988
+ return self._compute_topk(
989
+ list(range(n_q)),
990
+ query_shingles,
991
+ ref_shingles,
992
+ self._query_lsh(sigs_query, sigs_ref, index),
993
+ k_eff=min(k, n_r),
994
+ n_ref=n_r,
995
+ include_self=False,
996
+ )
997
+
998
+ def _compute_topk(
999
+ self,
1000
+ query_idx: list[int],
1001
+ query_shingles: Sequence[set[str]],
1002
+ ref_shingles: Sequence[set[str]],
1003
+ candidate_sets: list[set[int]],
1004
+ k_eff: int,
1005
+ n_ref: int,
1006
+ include_self: bool,
1007
+ ) -> tuple[np.ndarray, np.ndarray]:
1008
+ """Compute exact Jaccard on LSH candidates and return top-k per query."""
1009
+ n_q = len(query_idx)
1010
+ sims = np.zeros((n_q, k_eff), dtype=np.float64)
1011
+ indices = np.zeros((n_q, k_eff), dtype=np.int64)
1012
+ for i, qi in enumerate(query_idx):
1013
+ cands = candidate_sets[i]
1014
+ if include_self:
1015
+ cands = cands | {qi}
1016
+ scored: list[tuple[float, int]] = [
1017
+ (self._exact_jaccard(query_shingles[i], ref_shingles[j]), j) for j in cands
1018
+ ]
1019
+ # Stable descending sort: highest similarity first; on ties, lower index first.
1020
+ scored.sort(key=lambda t: (-t[0], t[1]))
1021
+ chosen = scored[:k_eff]
1022
+ for slot, (sim, j) in enumerate(chosen):
1023
+ sims[i, slot] = sim
1024
+ indices[i, slot] = j
1025
+ # Pad with arbitrary fillers if fewer than k_eff candidates.
1026
+ if len(chosen) < k_eff:
1027
+ seen = {j for _, j in chosen}
1028
+ fillers = [j for j in range(n_ref) if j not in seen]
1029
+ for slot in range(len(chosen), k_eff):
1030
+ if not fillers:
1031
+ break
1032
+ indices[i, slot] = fillers.pop(0)
1033
+ # sims[i, slot] stays 0.0 — filler.
1034
+ return sims, indices
1035
+
1036
+
1037
+ # ---------------------------------------------------------------------------
1038
+ # Orchestrators
1039
+ # ---------------------------------------------------------------------------
1040
+
1041
+
1042
+ def near_dedup(
1043
+ texts: list[str],
1044
+ threshold: float = DEFAULT_DEDUP_THRESHOLD,
1045
+ k_neighbors: int = 20,
1046
+ *,
1047
+ strategy: SimilarityStrategy | None = None,
1048
+ ) -> DedupReport:
1049
+ """Greedy near-deduplication via a pluggable similarity strategy.
1050
+
1051
+ Forward-scan: for each kept text ``i``, drop all ``j > i`` with
1052
+ ``similarity(i, j) >= threshold`` per the active strategy. Default
1053
+ strategy is :class:`TfidfCosineStrategy` with literal v0.1.0 defaults
1054
+ (preserved bit-for-bit; existing callers keep working unchanged).
1055
+
1056
+ Parameters
1057
+ ----------
1058
+ texts : list[str]
1059
+ threshold : float, optional
1060
+ Similarity threshold in (0, 1). Default 0.9. Strategy-specific scale:
1061
+ for cosine-style strategies (TF-IDF, embedding) similarities are in
1062
+ [-1, 1]; for hash-bucket dedup similarities are in {0.0, 1.0}; for
1063
+ Jaccard in [0, 1].
1064
+ k_neighbors : int, optional
1065
+ Maximum neighbors to consider per query. Default 20.
1066
+ strategy : SimilarityStrategy or None, optional
1067
+ Pluggable similarity backend. ``None`` (default) instantiates
1068
+ :class:`TfidfCosineStrategy` with constructor defaults.
1069
+
1070
+ Returns
1071
+ -------
1072
+ DedupReport
1073
+
1074
+ Raises
1075
+ ------
1076
+ TypeError
1077
+ If ``texts`` is not a list.
1078
+ ValueError
1079
+ If ``threshold`` is outside (0, 1).
1080
+
1081
+ Examples
1082
+ --------
1083
+ Default strategy (TF-IDF cosine):
1084
+
1085
+ >>> texts = ["the quick brown fox", "the quick brown fox!", "lorem ipsum"]
1086
+ >>> report = near_dedup(texts, threshold=0.8)
1087
+ >>> report.n_kept >= 2 # at most one near-duplicate dropped
1088
+ True
1089
+ >>> set(report.kept_indices) | {p[0] for p in report.dropped_pairs} == set(range(3))
1090
+ True
1091
+
1092
+ Custom strategy (exact-match-after-normalization):
1093
+
1094
+ >>> report_exact = near_dedup(
1095
+ ... ["foo", "FOO", "bar"], threshold=0.5,
1096
+ ... strategy=ExactNormalizedHashStrategy(),
1097
+ ... )
1098
+ >>> report_exact.n_kept # "foo"/"FOO" collide; "bar" isolated
1099
+ 2
1100
+
1101
+ Notes
1102
+ -----
1103
+ The orchestrator is strategy-agnostic: it dispatches to
1104
+ ``strategy.pairs_within(texts, k_neighbors)`` and applies the threshold
1105
+ plus forward-scan greedy drop logic. Different "senses of leakage"
1106
+ (lexical, semantic, exact, n-gram-set) are encoded by swapping the
1107
+ strategy.
1108
+
1109
+ **Order dependence**: forward-scan greedy is order-dependent — for any
1110
+ cluster of near-duplicates, the *first* occurrence is kept and later
1111
+ occurrences are dropped. To make dedup reproducible across re-runs
1112
+ that may permute the input, **sort inputs by a canonical key** (URL,
1113
+ document id, primary key) before calling. This is the canonical
1114
+ approach in modern dedup pipelines (Lee et al. 2022 ACL "NearDup";
1115
+ Penedo et al. 2023 RefinedWeb; Penedo et al. 2025 FineWeb2).
1116
+
1117
+ References
1118
+ ----------
1119
+ .. [1] Lee, K., et al. "Deduplicating training data makes language
1120
+ models better." ACL 2022.
1121
+ """
1122
+ if not isinstance(texts, list):
1123
+ raise TypeError(f"texts must be a list, got {type(texts).__name__}")
1124
+ n = len(texts)
1125
+ if n == 0:
1126
+ return DedupReport([], [], threshold, 0)
1127
+ if not 0.0 < threshold < 1.0:
1128
+ raise ValueError(f"threshold must be in (0, 1), got {threshold}")
1129
+
1130
+ active_strategy: SimilarityStrategy = (
1131
+ strategy if strategy is not None else TfidfCosineStrategy()
1132
+ )
1133
+ similarities, indices = active_strategy.pairs_within(texts, k_neighbors)
1134
+
1135
+ kept_mask = np.ones(n, dtype=bool)
1136
+ dropped: list[tuple[int, int, float]] = []
1137
+
1138
+ for i in range(n):
1139
+ if not kept_mask[i]:
1140
+ continue
1141
+ for sim, j in zip(similarities[i], indices[i], strict=True):
1142
+ j_int = int(j)
1143
+ if j_int == i or j_int < i:
1144
+ continue
1145
+ if float(sim) >= threshold and kept_mask[j_int]:
1146
+ kept_mask[j_int] = False
1147
+ dropped.append((j_int, i, float(sim)))
1148
+
1149
+ kept_indices = np.where(kept_mask)[0].tolist()
1150
+ return DedupReport(kept_indices, dropped, threshold, n)
1151
+
1152
+
1153
+ def audit_source_label_similarity(
1154
+ texts: Sequence[str],
1155
+ *,
1156
+ sources: Sequence[str] | None = None,
1157
+ labels: Sequence[object] | None = None,
1158
+ threshold: float = DEFAULT_DEDUP_THRESHOLD,
1159
+ k_neighbors: int = 20,
1160
+ strategy: SimilarityStrategy | None = None,
1161
+ include_within_source: bool = True,
1162
+ include_cross_source: bool = True,
1163
+ include_same_label: bool = True,
1164
+ include_cross_label: bool = True,
1165
+ ) -> SimilarityAuditReport:
1166
+ """Report high-similarity pairs without dropping rows.
1167
+
1168
+ This is the evidence-first complement to :func:`near_dedup`. It uses the
1169
+ same pluggable similarity strategies but returns pair findings annotated
1170
+ with source/label relationships so consumers can decide whether duplicates
1171
+ are leakage, benign repeated labels, or label conflicts.
1172
+
1173
+ Raises
1174
+ ------
1175
+ ValueError
1176
+ If ``threshold`` is not in (0, 1]; if ``k_neighbors < 1``; or if
1177
+ ``sources``/``labels`` is supplied but does not have the same
1178
+ length as ``texts``.
1179
+ """
1180
+ text_list = list(texts)
1181
+ n = len(text_list)
1182
+ if not 0.0 < threshold <= 1.0:
1183
+ raise ValueError(f"threshold must be in (0, 1], got {threshold}")
1184
+ if k_neighbors < 1:
1185
+ raise ValueError(f"k_neighbors must be >= 1, got {k_neighbors}")
1186
+ if sources is not None and len(sources) != n:
1187
+ raise ValueError("sources must have the same length as texts")
1188
+ if labels is not None and len(labels) != n:
1189
+ raise ValueError("labels must have the same length as texts")
1190
+ if n == 0:
1191
+ return SimilarityAuditReport([], threshold, 0, "none", k_neighbors)
1192
+
1193
+ source_list = list(sources) if sources is not None else None
1194
+ label_list = list(labels) if labels is not None else None
1195
+ active_strategy: SimilarityStrategy = (
1196
+ strategy if strategy is not None else TfidfCosineStrategy()
1197
+ )
1198
+ similarities, indices = active_strategy.pairs_within(text_list, k_neighbors)
1199
+
1200
+ findings: list[SimilarityAuditFinding] = []
1201
+ seen_pairs: set[tuple[int, int]] = set()
1202
+ for i in range(n):
1203
+ for sim, j in zip(similarities[i], indices[i], strict=True):
1204
+ j_int = int(j)
1205
+ if j_int == i:
1206
+ continue
1207
+ left, right = sorted((i, j_int))
1208
+ pair = (left, right)
1209
+ if pair in seen_pairs:
1210
+ continue
1211
+ seen_pairs.add(pair)
1212
+ similarity = float(sim)
1213
+ if similarity < threshold:
1214
+ continue
1215
+ if not _audit_pair_allowed(
1216
+ left,
1217
+ right,
1218
+ sources=source_list,
1219
+ labels=label_list,
1220
+ include_within_source=include_within_source,
1221
+ include_cross_source=include_cross_source,
1222
+ include_same_label=include_same_label,
1223
+ include_cross_label=include_cross_label,
1224
+ ):
1225
+ continue
1226
+ findings.append(
1227
+ SimilarityAuditFinding(
1228
+ left_index=left,
1229
+ right_index=right,
1230
+ similarity=similarity,
1231
+ relation=_similarity_relation(left, right, source_list, label_list),
1232
+ left_source=source_list[left] if source_list is not None else None,
1233
+ right_source=source_list[right] if source_list is not None else None,
1234
+ left_label=label_list[left] if label_list is not None else None,
1235
+ right_label=label_list[right] if label_list is not None else None,
1236
+ )
1237
+ )
1238
+
1239
+ findings.sort(
1240
+ key=lambda finding: (-finding.similarity, finding.left_index, finding.right_index)
1241
+ )
1242
+ return SimilarityAuditReport(
1243
+ findings=findings,
1244
+ threshold=threshold,
1245
+ n_input=n,
1246
+ strategy=type(active_strategy).__name__,
1247
+ k_neighbors=k_neighbors,
1248
+ )
1249
+
1250
+
1251
+ def _audit_pair_allowed(
1252
+ left: int,
1253
+ right: int,
1254
+ *,
1255
+ sources: Sequence[str] | None,
1256
+ labels: Sequence[object] | None,
1257
+ include_within_source: bool,
1258
+ include_cross_source: bool,
1259
+ include_same_label: bool,
1260
+ include_cross_label: bool,
1261
+ ) -> bool:
1262
+ """Return whether a candidate pair should be retained by audit filters."""
1263
+ if sources is not None:
1264
+ same_source = sources[left] == sources[right]
1265
+ if same_source and not include_within_source:
1266
+ return False
1267
+ if not same_source and not include_cross_source:
1268
+ return False
1269
+ if labels is not None:
1270
+ same_label = labels[left] == labels[right]
1271
+ if same_label and not include_same_label:
1272
+ return False
1273
+ if not same_label and not include_cross_label:
1274
+ return False
1275
+ return True
1276
+
1277
+
1278
+ def _similarity_relation(
1279
+ left: int,
1280
+ right: int,
1281
+ sources: Sequence[str] | None,
1282
+ labels: Sequence[object] | None,
1283
+ ) -> SimilarityRelation:
1284
+ """Classify the source/label relationship for an audit finding."""
1285
+ if sources is None and labels is None:
1286
+ return "unspecified"
1287
+ same_source = sources[left] == sources[right] if sources is not None else None
1288
+ same_label = labels[left] == labels[right] if labels is not None else None
1289
+ if same_source is None:
1290
+ return "same_label" if same_label else "cross_label"
1291
+ if same_label is None:
1292
+ return "within_source" if same_source else "cross_source"
1293
+ if same_source and same_label:
1294
+ return "within_source_same_label"
1295
+ if same_source and not same_label:
1296
+ return "within_source_cross_label"
1297
+ if not same_source and same_label:
1298
+ return "cross_source_same_label"
1299
+ return "cross_source_cross_label"
1300
+
1301
+
1302
+ def cross_dedup_pairs(
1303
+ train_texts: list[str],
1304
+ eval_texts: list[str],
1305
+ threshold: float = DEFAULT_DEDUP_THRESHOLD,
1306
+ k_neighbors: int = 20,
1307
+ *,
1308
+ strategy: SimilarityStrategy | None = None,
1309
+ ) -> list[tuple[int, int, float]]:
1310
+ """Return all (eval_idx, train_idx, similarity) tuples at or above threshold.
1311
+
1312
+ Companion to :func:`cross_dedup` that exposes the matched train neighbor
1313
+ indices instead of only the eval-side keep set. Used by
1314
+ :class:`eval_toolkit.leakage.CrossSplitLeakageCheck` in
1315
+ ``label_aware`` mode (v0.17.0) to split near-duplicate hits into
1316
+ same-label and cross-label findings.
1317
+
1318
+ Parameters mirror :func:`cross_dedup`. Returns ``[]`` when either side
1319
+ is empty.
1320
+
1321
+ Raises
1322
+ ------
1323
+ ValueError
1324
+ If ``threshold`` is not in (0, 1).
1325
+ """
1326
+ if not 0.0 < threshold < 1.0:
1327
+ raise ValueError(f"threshold must be in (0, 1), got {threshold}")
1328
+ if not train_texts or not eval_texts:
1329
+ return []
1330
+ active_strategy: SimilarityStrategy = (
1331
+ strategy if strategy is not None else TfidfCosineStrategy()
1332
+ )
1333
+ similarities, indices = active_strategy.pairs_across(eval_texts, train_texts, k_neighbors)
1334
+ if similarities.size == 0:
1335
+ return []
1336
+ out: list[tuple[int, int, float]] = []
1337
+ for eval_idx in range(similarities.shape[0]):
1338
+ for col in range(similarities.shape[1]):
1339
+ sim = float(similarities[eval_idx, col])
1340
+ if sim >= threshold:
1341
+ out.append((eval_idx, int(indices[eval_idx, col]), sim))
1342
+ return out
1343
+
1344
+
1345
+ def cross_dedup(
1346
+ train_texts: list[str],
1347
+ eval_texts: list[str],
1348
+ threshold: float = DEFAULT_DEDUP_THRESHOLD,
1349
+ k_neighbors: int = 20,
1350
+ *,
1351
+ strategy: SimilarityStrategy | None = None,
1352
+ ) -> list[int]:
1353
+ """Return eval indices to KEEP (drop those near-duplicate to any train text).
1354
+
1355
+ Used to scrub OOD eval slices of any train-pool leakage before reporting
1356
+ OOD metrics. The notion of "near-duplicate" is owned by ``strategy``.
1357
+
1358
+ Parameters
1359
+ ----------
1360
+ train_texts, eval_texts : list[str]
1361
+ threshold : float, optional
1362
+ Similarity threshold in (0, 1). Default 0.9.
1363
+ k_neighbors : int, optional
1364
+ Maximum neighbors to consider per eval text. Default 20.
1365
+ strategy : SimilarityStrategy or None, optional
1366
+ Pluggable similarity backend. ``None`` (default) instantiates
1367
+ :class:`TfidfCosineStrategy` with constructor defaults.
1368
+
1369
+ Returns
1370
+ -------
1371
+ list[int]
1372
+ Indices into ``eval_texts`` of rows that are NOT near-duplicate to
1373
+ any train text. Order preserved.
1374
+
1375
+ Examples
1376
+ --------
1377
+ >>> train = ["the quick brown fox", "lorem ipsum dolor sit amet"]
1378
+ >>> eval_set = ["the quick brown fox!", "completely different text"]
1379
+ >>> kept = cross_dedup(train, eval_set, threshold=0.8)
1380
+ >>> 1 in kept # second eval text has no train match
1381
+ True
1382
+
1383
+ Raises
1384
+ ------
1385
+ ValueError
1386
+ If ``threshold`` is not in (0, 1).
1387
+ """
1388
+ if not 0.0 < threshold < 1.0:
1389
+ raise ValueError(f"threshold must be in (0, 1), got {threshold}")
1390
+ if not train_texts:
1391
+ return list(range(len(eval_texts)))
1392
+ if not eval_texts:
1393
+ return []
1394
+
1395
+ active_strategy: SimilarityStrategy = (
1396
+ strategy if strategy is not None else TfidfCosineStrategy()
1397
+ )
1398
+ similarities, _indices = active_strategy.pairs_across(eval_texts, train_texts, k_neighbors)
1399
+ if similarities.size == 0:
1400
+ return list(range(len(eval_texts)))
1401
+ max_sim_per_eval = similarities.max(axis=1)
1402
+ keep_mask = max_sim_per_eval < threshold
1403
+ return [int(i) for i in np.where(keep_mask)[0]]