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.
- embedflow/__init__.py +25 -0
- embedflow/__main__.py +3 -0
- embedflow/analysis.py +192 -0
- embedflow/cache/__init__.py +4 -0
- embedflow/cache/base.py +28 -0
- embedflow/cache/persistent_cache.py +198 -0
- embedflow/cli.py +1200 -0
- embedflow/compatibility/__init__.py +28 -0
- embedflow/compatibility/candidate_gap.py +105 -0
- embedflow/compatibility/containment.py +17 -0
- embedflow/compatibility/evaluate.py +319 -0
- embedflow/compatibility/metrics.py +75 -0
- embedflow/compatibility/migration_depth.py +67 -0
- embedflow/compatibility/probe.py +34 -0
- embedflow/compatibility/report.py +102 -0
- embedflow/compatibility/t2.py +64 -0
- embedflow/config.py +455 -0
- embedflow/data/__init__.py +1 -0
- embedflow/data/registry/__init__.py +1 -0
- embedflow/data/registry/benchmark_profiles.jsonl +3 -0
- embedflow/data/registry/checksums.sha256 +4 -0
- embedflow/data/registry/migrations.jsonl +15 -0
- embedflow/data/registry/registry_manifest.json +16 -0
- embedflow/data/registry/research_summaries.json +55 -0
- embedflow/data/registry/schema_version.json +5 -0
- embedflow/frozen/T2_V1_FROZEN_SPEC.md +71 -0
- embedflow/frozen/T2_V1_FROZEN_SPEC.sha256 +1 -0
- embedflow/indexes/__init__.py +5 -0
- embedflow/indexes/base.py +60 -0
- embedflow/indexes/faiss_backend.py +240 -0
- embedflow/indexes/qdrant_backend.py +225 -0
- embedflow/metrics/__init__.py +3 -0
- embedflow/metrics/latency.py +50 -0
- embedflow/migration/__init__.py +3 -0
- embedflow/migration/compatibility.py +156 -0
- embedflow/migration/facade.py +312 -0
- embedflow/migration/materializer.py +190 -0
- embedflow/migration/planner.py +78 -0
- embedflow/migration/state.py +81 -0
- embedflow/models/__init__.py +4 -0
- embedflow/models/base.py +31 -0
- embedflow/models/huggingface.py +226 -0
- embedflow/registry/__init__.py +47 -0
- embedflow/registry/loader.py +785 -0
- embedflow/registry/matcher.py +197 -0
- embedflow/registry/schema.py +266 -0
- embedflow/runtime.py +115 -0
- embedflow/serving/__init__.py +3 -0
- embedflow/serving/api.py +161 -0
- embedflow/serving/engine.py +222 -0
- embedflow/serving/factory.py +3 -0
- embedflow/serving/schemas.py +39 -0
- embedflow-0.1.0.dist-info/METADATA +210 -0
- embedflow-0.1.0.dist-info/RECORD +64 -0
- embedflow-0.1.0.dist-info/WHEEL +5 -0
- embedflow-0.1.0.dist-info/entry_points.txt +2 -0
- embedflow-0.1.0.dist-info/licenses/LICENSE +178 -0
- embedflow-0.1.0.dist-info/top_level.txt +2 -0
- src/__init__.py +1 -0
- src/embed.py +123 -0
- src/probe_features.py +24 -0
- src/storage.py +51 -0
- src/t2_v1.py +21 -0
- 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
|
+
)
|