embedflow 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.
Files changed (64) hide show
  1. embedflow/__init__.py +25 -0
  2. embedflow/__main__.py +3 -0
  3. embedflow/analysis.py +192 -0
  4. embedflow/cache/__init__.py +4 -0
  5. embedflow/cache/base.py +28 -0
  6. embedflow/cache/persistent_cache.py +198 -0
  7. embedflow/cli.py +1200 -0
  8. embedflow/compatibility/__init__.py +28 -0
  9. embedflow/compatibility/candidate_gap.py +105 -0
  10. embedflow/compatibility/containment.py +17 -0
  11. embedflow/compatibility/evaluate.py +319 -0
  12. embedflow/compatibility/metrics.py +75 -0
  13. embedflow/compatibility/migration_depth.py +67 -0
  14. embedflow/compatibility/probe.py +34 -0
  15. embedflow/compatibility/report.py +102 -0
  16. embedflow/compatibility/t2.py +64 -0
  17. embedflow/config.py +455 -0
  18. embedflow/data/__init__.py +1 -0
  19. embedflow/data/registry/__init__.py +1 -0
  20. embedflow/data/registry/benchmark_profiles.jsonl +3 -0
  21. embedflow/data/registry/checksums.sha256 +4 -0
  22. embedflow/data/registry/migrations.jsonl +15 -0
  23. embedflow/data/registry/registry_manifest.json +16 -0
  24. embedflow/data/registry/research_summaries.json +55 -0
  25. embedflow/data/registry/schema_version.json +5 -0
  26. embedflow/frozen/T2_V1_FROZEN_SPEC.md +71 -0
  27. embedflow/frozen/T2_V1_FROZEN_SPEC.sha256 +1 -0
  28. embedflow/indexes/__init__.py +5 -0
  29. embedflow/indexes/base.py +60 -0
  30. embedflow/indexes/faiss_backend.py +240 -0
  31. embedflow/indexes/qdrant_backend.py +225 -0
  32. embedflow/metrics/__init__.py +3 -0
  33. embedflow/metrics/latency.py +50 -0
  34. embedflow/migration/__init__.py +3 -0
  35. embedflow/migration/compatibility.py +156 -0
  36. embedflow/migration/facade.py +312 -0
  37. embedflow/migration/materializer.py +190 -0
  38. embedflow/migration/planner.py +78 -0
  39. embedflow/migration/state.py +81 -0
  40. embedflow/models/__init__.py +4 -0
  41. embedflow/models/base.py +31 -0
  42. embedflow/models/huggingface.py +226 -0
  43. embedflow/registry/__init__.py +47 -0
  44. embedflow/registry/loader.py +785 -0
  45. embedflow/registry/matcher.py +197 -0
  46. embedflow/registry/schema.py +266 -0
  47. embedflow/runtime.py +115 -0
  48. embedflow/serving/__init__.py +3 -0
  49. embedflow/serving/api.py +161 -0
  50. embedflow/serving/engine.py +222 -0
  51. embedflow/serving/factory.py +3 -0
  52. embedflow/serving/schemas.py +39 -0
  53. embedflow-0.1.0.dist-info/METADATA +210 -0
  54. embedflow-0.1.0.dist-info/RECORD +64 -0
  55. embedflow-0.1.0.dist-info/WHEEL +5 -0
  56. embedflow-0.1.0.dist-info/entry_points.txt +2 -0
  57. embedflow-0.1.0.dist-info/licenses/LICENSE +178 -0
  58. embedflow-0.1.0.dist-info/top_level.txt +2 -0
  59. src/__init__.py +1 -0
  60. src/embed.py +123 -0
  61. src/probe_features.py +24 -0
  62. src/storage.py +51 -0
  63. src/t2_v1.py +21 -0
  64. src/utils.py +53 -0
@@ -0,0 +1,28 @@
1
+ """Research-facing compatibility analysis.
2
+
3
+ The functions in this package are deliberately separate from the serving
4
+ engine. Analysis with qrels/native target rankings is Mode A (research
5
+ evaluation); serving without a target index is Mode B (finite-tail/T2-v1
6
+ diagnostics).
7
+ """
8
+
9
+ from .candidate_gap import CandidateGapCurve, compute_candidate_gap_curve
10
+ from .containment import candidate_containment
11
+ from .evaluate import evaluate_models, evaluate_rankings, evaluate_with_native_rankings
12
+ from .migration_depth import observed_migration_depth, recommend_initial_k
13
+ from .probe import run_finite_pool_probe
14
+ from .t2 import T2Diagnostic, diagnose_t2
15
+
16
+ __all__ = [
17
+ "CandidateGapCurve",
18
+ "T2Diagnostic",
19
+ "candidate_containment",
20
+ "compute_candidate_gap_curve",
21
+ "diagnose_t2",
22
+ "evaluate_models",
23
+ "evaluate_rankings",
24
+ "evaluate_with_native_rankings",
25
+ "observed_migration_depth",
26
+ "recommend_initial_k",
27
+ "run_finite_pool_probe",
28
+ ]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import asdict, dataclass
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from .containment import candidate_containment
10
+ from .metrics import ndcg, paired_bootstrap
11
+ from .migration_depth import observed_migration_depth
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class CandidateGapCurve:
16
+ """One point on an evaluated target-quality curve."""
17
+
18
+ k: int
19
+ native_target_quality: float
20
+ restricted_target_quality: float
21
+ candidate_gap: float
22
+ containment: float
23
+ queries: int
24
+ gap_ci_low: float | None = None
25
+ gap_ci_high: float | None = None
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ return asdict(self)
29
+
30
+
31
+ def compute_candidate_gap_curve(
32
+ *,
33
+ source_rankings: Mapping[str, Sequence[str]],
34
+ target_scores: Mapping[str, Mapping[str, float]],
35
+ native_target_rankings: Mapping[str, Sequence[str]],
36
+ qrels: Mapping[str, Mapping[str, int]],
37
+ k_values: Sequence[int] = (10, 20, 50, 100, 200, 500),
38
+ quality_k: int = 10,
39
+ bootstrap_resamples: int = 0,
40
+ seed: int = 42,
41
+ ) -> list[CandidateGapCurve]:
42
+ """Compute G(K) and containment from native target evaluation artifacts.
43
+
44
+ ``target_scores`` must contain target scores for source candidates only;
45
+ this function never treats containment as candidate gap. A native target
46
+ ranking is required to establish M_T.
47
+ """
48
+ if not k_values:
49
+ raise ValueError("k_values must contain at least one positive depth")
50
+ normalized_k = []
51
+ seen_k: set[int] = set()
52
+ for value in k_values:
53
+ if isinstance(value, bool) or int(value) != value or int(value) < 1:
54
+ raise ValueError("k_values must contain positive integers")
55
+ depth = int(value)
56
+ if depth in seen_k:
57
+ raise ValueError(f"duplicate candidate depth K={depth}")
58
+ seen_k.add(depth); normalized_k.append(depth)
59
+ if isinstance(quality_k, bool) or int(quality_k) != quality_k or int(quality_k) < 1:
60
+ raise ValueError("quality_k must be a positive integer")
61
+ query_ids = [str(q) for q in source_rankings if str(q) in target_scores and str(q) in native_target_rankings and str(q) in qrels]
62
+ if not query_ids:
63
+ raise ValueError("no query IDs overlap source rankings, target scores, native rankings, and qrels")
64
+ points: list[CandidateGapCurve] = []
65
+ native_values = {qid: ndcg(native_target_rankings[qid], qrels[qid], quality_k) for qid in query_ids}
66
+ for raw_k in sorted(normalized_k):
67
+ native_quality = [native_values[qid] for qid in query_ids]
68
+ restricted_quality: list[float] = []
69
+ contains: list[float] = []
70
+ gaps: list[float] = []
71
+ for qid in query_ids:
72
+ source = [str(doc_id) for doc_id in source_rankings[qid]]
73
+ score_map = target_scores[qid]
74
+ candidates = source[:raw_k]
75
+ positions: dict[str, int] = {}
76
+ for position, doc_id in enumerate(source):
77
+ positions.setdefault(doc_id, position)
78
+ missing = [doc_id for doc_id in candidates if doc_id not in score_map]
79
+ if missing:
80
+ raise ValueError(f"target_scores missing {len(missing)} source candidates for query {qid!r}")
81
+ if any(not np.isfinite(float(score_map[doc_id])) for doc_id in candidates):
82
+ raise ValueError(f"target_scores contains non-finite score for query {qid!r}")
83
+ ranked = sorted(candidates, key=lambda doc_id: (-float(score_map[doc_id]), positions[doc_id]))
84
+ restricted_quality.append(ndcg(ranked, qrels[qid], quality_k))
85
+ contains.append(candidate_containment(candidates, native_target_rankings[qid], quality_k))
86
+ gaps.append(native_values[qid] - restricted_quality[-1])
87
+ mean_gap = float(np.mean(gaps))
88
+ low = high = None
89
+ if bootstrap_resamples:
90
+ _, low, high = paired_bootstrap(gaps, seed=seed, resamples=bootstrap_resamples)
91
+ points.append(CandidateGapCurve(
92
+ k=raw_k,
93
+ native_target_quality=float(np.mean(native_quality)),
94
+ restricted_target_quality=float(np.mean(restricted_quality)),
95
+ candidate_gap=mean_gap,
96
+ containment=float(np.mean(contains)),
97
+ queries=len(query_ids),
98
+ gap_ci_low=low,
99
+ gap_ci_high=high,
100
+ ))
101
+ return points
102
+
103
+
104
+ def observed_k_epsilon(curve: Sequence[CandidateGapCurve], epsilon: float = 0.01) -> int | None:
105
+ return observed_migration_depth([point.to_dict() for point in curve], epsilon=epsilon)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+
6
+ def candidate_containment(source_ranked: Sequence[str], target_ranked: Sequence[str], k: int = 10) -> float:
7
+ """Fraction of the target top-k that appears in the source candidate list."""
8
+ if isinstance(k, bool) or int(k) != k or int(k) < 1:
9
+ raise ValueError("k must be a positive integer")
10
+ k = int(k)
11
+ target = set(map(str, target_ranked[:k]))
12
+ return float(len(target & set(map(str, source_ranked))) / len(target)) if target else 0.0
13
+
14
+
15
+ def containment_at_k(source_ranked: Sequence[str], target_ranked: Sequence[str], k: int = 10) -> float:
16
+ """Alias retained for reports and downstream notebooks."""
17
+ return candidate_containment(source_ranked, target_ranked, k=k)
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from ..indexes import FaissIndex, NumpyIndex, QdrantIndex
12
+ from ..migration.state import DocumentStore
13
+ from ..models import EmbeddingModel
14
+ from .candidate_gap import compute_candidate_gap_curve
15
+ from .containment import candidate_containment
16
+ from .metrics import ndcg, recall
17
+ from .report import migration_report
18
+
19
+
20
+ def load_queries(path: str | Path) -> list[tuple[str, str]]:
21
+ path = Path(path)
22
+ if not path.exists():
23
+ raise FileNotFoundError(path)
24
+ rows: list[tuple[str, str]] = []
25
+ seen: set[str] = set()
26
+ with path.open() as handle:
27
+ for number, line in enumerate(handle, 1):
28
+ if not line.strip():
29
+ continue
30
+ try:
31
+ row = json.loads(line)
32
+ except json.JSONDecodeError as exc:
33
+ raise ValueError(f"invalid query JSON at {path}:{number}") from exc
34
+ if not isinstance(row, dict):
35
+ raise ValueError(f"query row {number} in {path} must be a JSON object")
36
+ query_id = str(row.get("id", row.get("query_id", number)))
37
+ text = row.get("text", row.get("query"))
38
+ if not isinstance(text, str) or not text.strip():
39
+ raise ValueError(f"query {query_id!r} has no text")
40
+ if query_id in seen:
41
+ raise ValueError(f"duplicate query ID {query_id!r} in {path}")
42
+ seen.add(query_id)
43
+ rows.append((query_id, text))
44
+ if not rows:
45
+ raise ValueError(f"query file is empty: {path}")
46
+ return rows
47
+
48
+
49
+ def load_qrels(path: str | Path) -> dict[str, dict[str, int]]:
50
+ path = Path(path)
51
+ if not path.exists():
52
+ raise FileNotFoundError(path)
53
+ try:
54
+ raw = json.loads(path.read_text())
55
+ except json.JSONDecodeError as exc:
56
+ raise ValueError(f"qrels is not valid JSON: {path}") from exc
57
+ if not isinstance(raw, dict):
58
+ raise ValueError("qrels must be a JSON object mapping query IDs to document relevance maps")
59
+ output: dict[str, dict[str, int]] = {}
60
+ for query_id, labels in raw.items():
61
+ if not isinstance(labels, Mapping):
62
+ raise ValueError(f"qrels for query {query_id!r} must be an object")
63
+ cleaned: dict[str, int] = {}
64
+ for document_id, value in labels.items():
65
+ if isinstance(value, bool):
66
+ raise ValueError("qrel relevance values must be finite non-negative integers")
67
+ try:
68
+ numeric = float(value)
69
+ except (TypeError, ValueError) as exc:
70
+ raise ValueError("qrel relevance values must be finite non-negative integers") from exc
71
+ if not math.isfinite(numeric) or numeric < 0 or int(numeric) != numeric:
72
+ raise ValueError("qrel relevance values must be finite non-negative integers")
73
+ cleaned[str(document_id)] = int(numeric)
74
+ output[str(query_id)] = cleaned
75
+ return output
76
+
77
+
78
+ def _load_index(path: str | Path, backend: str, metric: str, documents: dict[str, str], dimension: int | None = None):
79
+ if backend == "faiss":
80
+ try:
81
+ return FaissIndex.load(path, metric=metric, documents=documents)
82
+ except RuntimeError as exc:
83
+ try:
84
+ return NumpyIndex.load(path, metric=metric, documents=documents)
85
+ except Exception as fallback_exc:
86
+ raise exc from fallback_exc
87
+ if backend == "qdrant":
88
+ return QdrantIndex.connect(str(path), "embedflow", int(dimension or 0), documents=documents, metric=metric)
89
+ raise ValueError(f"unsupported index backend: {backend}")
90
+
91
+
92
+ def _rank_scores(scores: Mapping[str, float], *, tie_order: Sequence[str]) -> list[str]:
93
+ return sorted((str(doc_id) for doc_id in scores), key=lambda doc_id: (-float(scores[doc_id]), tie_order.index(doc_id) if doc_id in tie_order else len(tie_order)))
94
+
95
+
96
+ def ann_fidelity(source_rankings: Mapping[str, Sequence[str]], reference_rankings: Mapping[str, Sequence[str]], k: int = 100) -> dict[str, Any]:
97
+ """Compare source ANN rankings with an exact/reference source ranking."""
98
+ if isinstance(k, bool) or int(k) != k or int(k) < 1:
99
+ raise ValueError("ANN audit k must be a positive integer")
100
+ values = []
101
+ for query_id, ranking in source_rankings.items():
102
+ reference = reference_rankings.get(query_id)
103
+ if reference is None:
104
+ continue
105
+ values.append(candidate_containment(ranking, reference, k=k))
106
+ if not values:
107
+ return {"status": "UNKNOWN", "queries": 0, "note": "No overlapping source/reference rankings were supplied."}
108
+ mean = float(np.mean(values))
109
+ return {
110
+ "status": "PASS" if mean >= 0.95 else "WARNING",
111
+ "queries": len(values),
112
+ "k": int(k),
113
+ "mean_overlap": mean,
114
+ "note": "Overlap with the supplied exact/reference source retrieval; this is not a T2-v1 measurement.",
115
+ }
116
+
117
+
118
+ def evaluate_rankings(
119
+ *,
120
+ source_rankings: Mapping[str, Sequence[str]],
121
+ native_target_rankings: Mapping[str, Sequence[str]],
122
+ target_scores: Mapping[str, Mapping[str, float]],
123
+ qrels: Mapping[str, Mapping[str, int]],
124
+ source_model: str,
125
+ target_model: str,
126
+ corpus_size: int,
127
+ k_values: Sequence[int] = (10, 20, 50, 100, 200, 500),
128
+ quality_k: int = 10,
129
+ epsilon: float = 0.01,
130
+ bootstrap_resamples: int = 0,
131
+ seed: int = 42,
132
+ diagnostic: str | None = None,
133
+ recommended_k: int | None = None,
134
+ ann_status: str = "UNKNOWN",
135
+ ) -> dict[str, Any]:
136
+ if not k_values:
137
+ raise ValueError("k_values must contain at least one positive depth")
138
+ if len(set(k_values)) != len(k_values) or any(isinstance(k, bool) or int(k) != k or int(k) < 1 for k in k_values):
139
+ raise ValueError("k_values must contain unique positive integers")
140
+ if isinstance(quality_k, bool) or int(quality_k) != quality_k or int(quality_k) < 1:
141
+ raise ValueError("quality_k must be a positive integer")
142
+ curve = compute_candidate_gap_curve(
143
+ source_rankings=source_rankings,
144
+ target_scores=target_scores,
145
+ native_target_rankings=native_target_rankings,
146
+ qrels=qrels,
147
+ k_values=k_values,
148
+ quality_k=quality_k,
149
+ bootstrap_resamples=bootstrap_resamples,
150
+ seed=seed,
151
+ )
152
+ shared = [qid for qid in source_rankings if qid in qrels and qid in native_target_rankings]
153
+ source_quality = [ndcg(source_rankings[qid], qrels[qid], quality_k) for qid in shared]
154
+ source_recall = [recall(source_rankings[qid], qrels[qid], max(k_values)) for qid in shared]
155
+ native_quality = [ndcg(native_target_rankings[qid], qrels[qid], quality_k) for qid in shared]
156
+ result = migration_report(
157
+ source_model=source_model,
158
+ target_model=target_model,
159
+ corpus_size=corpus_size,
160
+ curve=curve,
161
+ diagnostic=diagnostic,
162
+ recommended_k=recommended_k,
163
+ ann_status=ann_status,
164
+ epsilon=epsilon,
165
+ native_target_index_used=True,
166
+ )
167
+ result["source_quality"] = {"ndcg_at_k": float(np.mean(source_quality)) if source_quality else 0.0,
168
+ "recall_at_k": float(np.mean(source_recall)) if source_recall else 0.0,
169
+ "k": max(k_values)}
170
+ result["native_target_quality"] = {"ndcg_at_k": float(np.mean(native_quality)) if native_quality else 0.0, "k": quality_k}
171
+ result["queries_evaluated"] = len(shared)
172
+ result["bootstrap_resamples"] = int(bootstrap_resamples)
173
+ return result
174
+
175
+
176
+ def evaluate_models(
177
+ *,
178
+ source_model: EmbeddingModel,
179
+ target_model: EmbeddingModel,
180
+ source_index: Any,
181
+ documents: DocumentStore,
182
+ queries: Iterable[tuple[str, str]],
183
+ qrels: Mapping[str, Mapping[str, int]],
184
+ native_target_index: Any | None = None,
185
+ reference_source_index: Any | None = None,
186
+ k_values: Sequence[int] = (10, 20, 50, 100, 200, 500),
187
+ quality_k: int = 10,
188
+ epsilon: float = 0.01,
189
+ bootstrap_resamples: int = 0,
190
+ seed: int = 42,
191
+ diagnostic: str | None = None,
192
+ recommended_k: int | None = None,
193
+ ) -> dict[str, Any]:
194
+ """Evaluate Mode A using qrels and optional native/reference indexes.
195
+
196
+ If a native target index is omitted, the target model encodes the corpus
197
+ in-process to establish M_T. That is correct but potentially expensive;
198
+ production-scale users should provide a native target index or saved
199
+ rankings instead.
200
+ """
201
+ queries = [(str(qid), str(text)) for qid, text in queries if str(qid) in qrels]
202
+ if not queries:
203
+ raise ValueError("no evaluation queries overlap qrels")
204
+ if source_index.size() < 1:
205
+ raise ValueError("source index is empty")
206
+ if not k_values or any(isinstance(k, bool) or int(k) != k or int(k) < 1 for k in k_values):
207
+ raise ValueError("k_values must contain positive integers")
208
+ max_k = min(max(int(k) for k in k_values), source_index.size())
209
+ source_rankings: dict[str, list[str]] = {}
210
+ source_vectors: dict[str, np.ndarray] = {}
211
+ target_query_vectors: dict[str, np.ndarray] = {}
212
+ for query_id, text in queries:
213
+ source_vector = np.asarray(source_model.encode_query(text), dtype="float32")
214
+ source_vectors[query_id] = source_vector
215
+ source_rankings[query_id] = [hit.document_id for hit in source_index.search(source_vector, max_k)]
216
+ target_query_vectors[query_id] = np.asarray(target_model.encode_query(text), dtype="float32")
217
+
218
+ candidate_ids = list(dict.fromkeys(doc_id for ids in source_rankings.values() for doc_id in ids))
219
+ candidate_texts = documents.get(candidate_ids)
220
+ candidate_vectors = target_model.encode_documents([candidate_texts[doc_id] for doc_id in candidate_ids], batch_size=32)
221
+ candidate_vectors = np.asarray(candidate_vectors, dtype="float32")
222
+ target_scores = {
223
+ query_id: {doc_id: float(candidate_vectors[index] @ target_query_vectors[query_id]) for index, doc_id in enumerate(candidate_ids)}
224
+ for query_id in source_rankings
225
+ }
226
+
227
+ native_target_rankings: dict[str, list[str]] = {}
228
+ if native_target_index is not None:
229
+ for query_id, _ in queries:
230
+ native_target_rankings[query_id] = [hit.document_id for hit in native_target_index.search(target_query_vectors[query_id], documents.size())]
231
+ else:
232
+ all_ids = list(documents.documents)
233
+ all_texts = documents.get(all_ids)
234
+ all_vectors = np.asarray(target_model.encode_documents([all_texts[doc_id] for doc_id in all_ids], batch_size=32), dtype="float32")
235
+ for query_id, _ in queries:
236
+ scores = all_vectors @ target_query_vectors[query_id]
237
+ order = np.lexsort((np.arange(len(all_ids)), -scores))
238
+ native_target_rankings[query_id] = [all_ids[int(index)] for index in order]
239
+
240
+ ann_status = "UNKNOWN"
241
+ if reference_source_index is not None:
242
+ reference_rankings = {query_id: [hit.document_id for hit in reference_source_index.search(source_vectors[query_id], max_k)] for query_id, _ in queries}
243
+ ann_status = ann_fidelity(source_rankings, reference_rankings, k=max_k)["status"]
244
+ return evaluate_rankings(
245
+ source_rankings=source_rankings,
246
+ native_target_rankings=native_target_rankings,
247
+ target_scores=target_scores,
248
+ qrels=qrels,
249
+ source_model=source_model.model_id,
250
+ target_model=target_model.model_id,
251
+ corpus_size=documents.size(),
252
+ k_values=k_values,
253
+ quality_k=quality_k,
254
+ epsilon=epsilon,
255
+ bootstrap_resamples=bootstrap_resamples,
256
+ seed=seed,
257
+ diagnostic=diagnostic,
258
+ recommended_k=recommended_k,
259
+ ann_status=ann_status,
260
+ )
261
+
262
+
263
+ def evaluate_with_native_rankings(
264
+ *,
265
+ source_model: EmbeddingModel,
266
+ target_model: EmbeddingModel,
267
+ source_index: Any,
268
+ documents: DocumentStore,
269
+ queries: Iterable[tuple[str, str]],
270
+ native_target_rankings: Mapping[str, Sequence[str]],
271
+ qrels: Mapping[str, Mapping[str, int]],
272
+ reference_source_index: Any | None = None,
273
+ k_values: Sequence[int] = (10, 20, 50, 100, 200, 500),
274
+ quality_k: int = 10,
275
+ epsilon: float = 0.01,
276
+ bootstrap_resamples: int = 0,
277
+ seed: int = 42,
278
+ diagnostic: str | None = None,
279
+ recommended_k: int | None = None,
280
+ ) -> dict[str, Any]:
281
+ """Evaluate against saved native target rankings without a target index."""
282
+ queries = [(str(qid), str(text)) for qid, text in queries if str(qid) in qrels and str(qid) in native_target_rankings]
283
+ if not queries:
284
+ raise ValueError("no evaluation queries overlap qrels and native target rankings")
285
+ if source_index.size() < 1:
286
+ raise ValueError("source index is empty")
287
+ if not k_values or any(isinstance(k, bool) or int(k) != k or int(k) < 1 for k in k_values):
288
+ raise ValueError("k_values must contain positive integers")
289
+ max_k = min(max(int(k) for k in k_values), source_index.size())
290
+ source_rankings: dict[str, list[str]] = {}
291
+ target_query_vectors: dict[str, np.ndarray] = {}
292
+ for query_id, text in queries:
293
+ source_rankings[query_id] = [hit.document_id for hit in source_index.search(source_model.encode_query(text), max_k)]
294
+ target_query_vectors[query_id] = np.asarray(target_model.encode_query(text), dtype="float32")
295
+ candidate_ids = list(dict.fromkeys(doc_id for ids in source_rankings.values() for doc_id in ids))
296
+ candidate_texts = documents.get(candidate_ids)
297
+ vectors = np.asarray(target_model.encode_documents([candidate_texts[doc_id] for doc_id in candidate_ids], batch_size=32), dtype="float32")
298
+ target_scores = {qid: {doc_id: float(vectors[i] @ target_query_vectors[qid]) for i, doc_id in enumerate(candidate_ids)} for qid, _ in queries}
299
+ ann_status = "UNKNOWN"
300
+ if reference_source_index is not None:
301
+ reference = {qid: [hit.document_id for hit in reference_source_index.search(source_model.encode_query(text), max_k)] for qid, text in queries}
302
+ ann_status = ann_fidelity(source_rankings, reference, k=max_k)["status"]
303
+ return evaluate_rankings(
304
+ source_rankings=source_rankings,
305
+ native_target_rankings={qid: list(map(str, native_target_rankings[qid])) for qid, _ in queries},
306
+ target_scores=target_scores,
307
+ qrels=qrels,
308
+ source_model=source_model.model_id,
309
+ target_model=target_model.model_id,
310
+ corpus_size=documents.size(),
311
+ k_values=k_values,
312
+ quality_k=quality_k,
313
+ epsilon=epsilon,
314
+ bootstrap_resamples=bootstrap_resamples,
315
+ seed=seed,
316
+ diagnostic=diagnostic,
317
+ recommended_k=recommended_k,
318
+ ann_status=ann_status,
319
+ )
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Mapping, Sequence
5
+
6
+ import numpy as np
7
+
8
+
9
+ def ndcg(ranked_ids: Sequence[str], qrels: Mapping[str, int], k: int = 10) -> float:
10
+ """Compute nDCG@k for one query using graded relevance labels."""
11
+ if isinstance(k, bool) or int(k) != k or int(k) < 1:
12
+ raise ValueError("k must be a positive integer")
13
+ if not isinstance(qrels, Mapping):
14
+ raise TypeError("qrels must be a mapping")
15
+ clean_qrels: dict[str, int] = {}
16
+ for doc_id, relevance in qrels.items():
17
+ try:
18
+ numeric = float(relevance)
19
+ except (TypeError, ValueError) as exc:
20
+ raise ValueError("qrel relevance values must be finite numbers") from exc
21
+ if not math.isfinite(numeric) or numeric < 0 or int(numeric) != numeric:
22
+ raise ValueError("qrel relevance values must be finite non-negative integers")
23
+ clean_qrels[str(doc_id)] = int(numeric)
24
+ # Retrieval backends should return unique IDs, but malformed sidecars and
25
+ # user-provided rankings do occur. Score only the first occurrence so a
26
+ # duplicate cannot inflate DCG above the ideal ranking.
27
+ ranked = []
28
+ seen: set[str] = set()
29
+ for value in ranked_ids:
30
+ document_id = str(value)
31
+ if document_id in seen:
32
+ continue
33
+ seen.add(document_id)
34
+ ranked.append(document_id)
35
+ if len(ranked) >= int(k):
36
+ break
37
+ values = [clean_qrels.get(x, 0) for x in ranked]
38
+ dcg = sum((2**value - 1) / np.log2(position + 2) for position, value in enumerate(values))
39
+ ideal = sorted(clean_qrels.values(), reverse=True)[: int(k)]
40
+ idcg = sum((2**value - 1) / np.log2(position + 2) for position, value in enumerate(ideal))
41
+ return float(dcg / idcg) if idcg else 0.0
42
+
43
+
44
+ def recall(ranked_ids: Sequence[str], qrels: Mapping[str, int], k: int = 100) -> float:
45
+ if isinstance(k, bool) or int(k) != k or int(k) < 1:
46
+ raise ValueError("k must be a positive integer")
47
+ if not isinstance(qrels, Mapping):
48
+ raise TypeError("qrels must be a mapping")
49
+ positives = set()
50
+ for doc_id, relevance in qrels.items():
51
+ if isinstance(relevance, bool):
52
+ raise ValueError("qrel relevance values must be finite non-negative numbers")
53
+ try:
54
+ numeric = float(relevance)
55
+ except (TypeError, ValueError) as exc:
56
+ raise ValueError("qrel relevance values must be finite non-negative numbers") from exc
57
+ if not math.isfinite(numeric) or numeric < 0:
58
+ raise ValueError("qrel relevance values must be finite non-negative numbers")
59
+ if numeric > 0:
60
+ positives.add(str(doc_id))
61
+ return float(len(set(map(str, ranked_ids[: int(k)])) & positives) / len(positives)) if positives else 0.0
62
+
63
+
64
+ def paired_bootstrap(values: Sequence[float], seed: int = 42, resamples: int = 10_000) -> tuple[float, float, float]:
65
+ """Return mean and a percentile bootstrap interval for paired query values."""
66
+ values = np.asarray(list(values), dtype="float64")
67
+ if values.size == 0:
68
+ raise ValueError("bootstrap requires at least one value")
69
+ if isinstance(resamples, bool) or int(resamples) != resamples or int(resamples) < 1:
70
+ raise ValueError("resamples must be a positive integer")
71
+ if not np.isfinite(values).all():
72
+ raise ValueError("bootstrap values must be finite")
73
+ rng = np.random.default_rng(int(seed))
74
+ draws = values[rng.integers(0, values.size, size=(int(resamples), values.size))].mean(axis=1)
75
+ return float(values.mean()), float(np.quantile(draws, 0.025)), float(np.quantile(draws, 0.975))
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Mapping, Sequence
5
+ from typing import Any
6
+
7
+
8
+ def observed_migration_depth(gap_curve: Sequence[Mapping[str, Any]], epsilon: float = 0.01) -> int | None:
9
+ """Return observed K*_epsilon from an evaluated G(K) curve.
10
+
11
+ This is only valid when the curve was computed against qrels/native target
12
+ retrieval. It must not be used for the no-target-index T2-v1 workflow.
13
+ """
14
+ try:
15
+ epsilon_value = float(epsilon)
16
+ except (TypeError, ValueError, OverflowError) as exc:
17
+ raise ValueError("epsilon must be non-negative") from exc
18
+ if not math.isfinite(epsilon_value) or epsilon_value < 0:
19
+ raise ValueError("epsilon must be non-negative")
20
+ seen: set[int] = set()
21
+ normalized = []
22
+ for row in gap_curve:
23
+ try:
24
+ k = int(row["k"])
25
+ gap = float(row["candidate_gap"])
26
+ except (KeyError, TypeError, ValueError) as exc:
27
+ raise ValueError("gap curve rows require numeric k and candidate_gap") from exc
28
+ try:
29
+ k_exact = float(row["k"]) == k
30
+ except (TypeError, ValueError, OverflowError):
31
+ k_exact = False
32
+ if not k_exact or k < 1 or not math.isfinite(gap):
33
+ raise ValueError("gap curve k must be positive and candidate_gap finite")
34
+ if k in seen:
35
+ raise ValueError(f"duplicate candidate depth K={k}")
36
+ seen.add(k); normalized.append((k, gap))
37
+ for k, gap in sorted(normalized):
38
+ if gap <= epsilon_value:
39
+ return k
40
+ return None
41
+
42
+
43
+ def recommend_initial_k(probe_result: Mapping[str, Any], *, default: int = 50) -> tuple[int, str]:
44
+ """Name the deployment recommendation without calling it K*.
45
+
46
+ T2-v1 has no native target metric and therefore cannot identify an
47
+ observed migration depth. The returned label intentionally says
48
+ ``recommended_initial_k`` rather than ``K*``.
49
+ """
50
+ if isinstance(default, bool) or int(default) != default or int(default) < 1:
51
+ raise ValueError("default candidate depth must be a positive integer")
52
+ if not isinstance(probe_result, Mapping):
53
+ raise TypeError("probe_result must be a mapping")
54
+ diagnostic = str(probe_result.get("diagnostic", "UNSAFE_OR_UNCERTAIN")).upper()
55
+ raw_suggested = default if probe_result.get("recommended_k") is None else probe_result.get("recommended_k")
56
+ try:
57
+ suggested = int(raw_suggested)
58
+ suggested_exact = float(raw_suggested) == suggested
59
+ except (TypeError, ValueError, OverflowError):
60
+ suggested, suggested_exact = 0, False
61
+ if not suggested_exact or suggested < 1:
62
+ raise ValueError("recommended candidate depth must be positive")
63
+ if diagnostic == "SAFE":
64
+ return suggested, "Finite-tail behavior supports this starting K; validate on production traffic."
65
+ if diagnostic == "EXPAND":
66
+ return max(suggested, int(default)), "Expand the source candidate depth and perform manual validation."
67
+ return max(suggested, int(default)), "Evidence is insufficient; prefer manual validation or a full target backfill."
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ def run_finite_pool_probe(
10
+ source_model: Any,
11
+ target_model: Any,
12
+ source_index: Any,
13
+ documents: Any,
14
+ queries: Iterable[tuple[str, str]],
15
+ *,
16
+ kmax: int = 500,
17
+ seed: int = 42,
18
+ limit: int | None = None,
19
+ target_document_vectors: Mapping[str, np.ndarray] | None = None,
20
+ ) -> dict[str, Any]:
21
+ """Run the existing leakage-safe T2-v1 probe implementation."""
22
+ from ..migration.compatibility import run_probe
23
+
24
+ return run_probe(
25
+ source_model,
26
+ target_model,
27
+ source_index,
28
+ documents,
29
+ queries,
30
+ kmax=kmax,
31
+ seed=seed,
32
+ limit=limit,
33
+ target_document_vectors=target_document_vectors,
34
+ )