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,197 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable, Mapping
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .schema import (
9
+ MATCH_EXACT,
10
+ MATCH_NONE,
11
+ MATCH_PRIOR,
12
+ MATCH_RELATED,
13
+ EvidenceRecord,
14
+ contract_fingerprint,
15
+ )
16
+
17
+ _ALIASES = {
18
+ "minilm_l6": "sentence-transformers/all-MiniLM-L6-v2",
19
+ "minilm-l6": "sentence-transformers/all-MiniLM-L6-v2",
20
+ "minilm-l6-v2": "sentence-transformers/all-MiniLM-L6-v2",
21
+ "sentence-transformers/all-minilm-l6-v2": "sentence-transformers/all-MiniLM-L6-v2",
22
+ "qwen3-0.6b": "Qwen/Qwen3-Embedding-0.6B",
23
+ "qwen3_0_6b": "Qwen/Qwen3-Embedding-0.6B",
24
+ "qwen/qwen3-embedding-0.6b": "Qwen/Qwen3-Embedding-0.6B",
25
+ "qwen3-4b": "Qwen/Qwen3-Embedding-4B",
26
+ "qwen3_4b": "Qwen/Qwen3-Embedding-4B",
27
+ "qwen/qwen3-embedding-4b": "Qwen/Qwen3-Embedding-4B",
28
+ "qwen3-8b": "Qwen/Qwen3-Embedding-8B",
29
+ "qwen3_8b": "Qwen/Qwen3-Embedding-8B",
30
+ "qwen/qwen3-embedding-8b": "Qwen/Qwen3-Embedding-8B",
31
+ }
32
+
33
+
34
+ def canonical_model_id(value: Any) -> str:
35
+ if isinstance(value, Mapping):
36
+ value = value.get("canonical_model_id", value.get("model", value.get("model_id", "")))
37
+ else:
38
+ value = getattr(value, "model", getattr(value, "model_id", value))
39
+ text = str(value or "").strip()
40
+ return _ALIASES.get(text.lower(), text)
41
+
42
+
43
+ def _contract(value: Any) -> tuple[str, str | None]:
44
+ if isinstance(value, Mapping):
45
+ ident = canonical_model_id(value)
46
+ fp = value.get("contract_fingerprint") or value.get("fingerprint")
47
+ if fp is None and any(key in value for key in ("revision", "dimension", "pooling", "max_length", "normalization")):
48
+ candidate = dict(value)
49
+ candidate["canonical_model_id"] = ident
50
+ fp = contract_fingerprint(candidate)
51
+ return ident, str(fp) if fp else None
52
+ ident = canonical_model_id(value)
53
+ if hasattr(value, "contract"):
54
+ raw = dict(value.contract())
55
+ raw["canonical_model_id"] = ident
56
+ return ident, contract_fingerprint(raw)
57
+ if hasattr(value, "fingerprint"):
58
+ return ident, str(value.fingerprint)
59
+ # A bare model ID can identify a prior transition for display, but cannot
60
+ # establish exact contract equivalence (revision/prompt/etc. are unknown).
61
+ return ident, None
62
+
63
+
64
+ def _family(value: str) -> str:
65
+ text = value.lower().replace("embedding", "").replace("sentence-transformers/", "")
66
+ if "minilm" in text:
67
+ return "minilm"
68
+ # Keep the family intentionally broad: a Qwen3 revision/size that is not
69
+ # in the core rows can still receive RELATED evidence, but never an
70
+ # automatic decision. Contract-level matching above remains strict.
71
+ if re.search(r"qwen3", text):
72
+ return "qwen3"
73
+ return text
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class RegistryMatch:
78
+ """Evidence lookup result; only EXACT matches can be reused as results."""
79
+
80
+ level: str
81
+ records: tuple[EvidenceRecord, ...]
82
+ exact_source_contract: bool
83
+ exact_target_contract: bool
84
+ exact_corpus: bool
85
+ reason: str
86
+ recommended_k: tuple[int, ...] = ()
87
+
88
+ @property
89
+ def prior_datasets(self) -> tuple[str, ...]:
90
+ return tuple(dict.fromkeys(str(row.dataset.get("name")) for row in self.records))
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ return {
94
+ "level": self.level,
95
+ "exact_source_contract": self.exact_source_contract,
96
+ "exact_target_contract": self.exact_target_contract,
97
+ "exact_corpus": self.exact_corpus,
98
+ "reason": self.reason,
99
+ "prior_datasets": list(self.prior_datasets),
100
+ "recommended_k": list(self.recommended_k),
101
+ "evidence_ids": [row.evidence_id for row in self.records],
102
+ }
103
+
104
+
105
+ def match_evidence(
106
+ *,
107
+ source_model: Any,
108
+ target_model: Any,
109
+ corpus_fingerprint: str | None = None,
110
+ corpus_name: str | None = None,
111
+ corpus_size: int | None = None,
112
+ records: Iterable[EvidenceRecord],
113
+ ) -> RegistryMatch:
114
+ source_id, source_fp = _contract(source_model)
115
+ target_id, target_fp = _contract(target_model)
116
+ # ``records`` is intentionally typed as an iterable so callers can stream
117
+ # custom registries. Materialise it once: transition and family matching
118
+ # both need to inspect the same rows, and consuming a generator twice
119
+ # would otherwise produce a false ``NO REGISTRY MATCH`` result.
120
+ rows = tuple(records)
121
+ source_family, target_family = _family(source_id), _family(target_id)
122
+ transition = tuple(
123
+ row for row in rows
124
+ if canonical_model_id(row.source) == source_id and canonical_model_id(row.target) == target_id
125
+ )
126
+ if not transition:
127
+ family_rows = tuple(
128
+ row for row in rows
129
+ if _family(canonical_model_id(row.source)) == source_family
130
+ and _family(canonical_model_id(row.target)) == target_family
131
+ )
132
+ return RegistryMatch(
133
+ MATCH_RELATED if family_rows else MATCH_NONE,
134
+ family_rows,
135
+ False,
136
+ False,
137
+ False,
138
+ "No identical transition was found; related family evidence is not a compatibility decision." if family_rows else "No registry record matches this transition.",
139
+ _priority_k(family_rows),
140
+ )
141
+ exact_source = source_fp is not None and all(str(row.source.get("contract_fingerprint")) == source_fp for row in transition)
142
+ exact_target = target_fp is not None and all(str(row.target.get("contract_fingerprint")) == target_fp for row in transition)
143
+ corpus_matches = []
144
+ for row in transition:
145
+ dataset = row.dataset
146
+ fingerprint_match = corpus_fingerprint is not None and dataset.get("fingerprint") is not None and str(dataset.get("fingerprint")) == str(corpus_fingerprint)
147
+ canonical_match = (
148
+ corpus_name is not None and str(dataset.get("canonical_dataset_id", dataset.get("name"))) == str(corpus_name)
149
+ and corpus_size is not None and dataset.get("corpus_size") is not None and int(dataset["corpus_size"]) == int(corpus_size)
150
+ and bool(dataset.get("canonical_construction", False))
151
+ )
152
+ corpus_matches.append(bool(fingerprint_match or canonical_match))
153
+ exact_corpus = any(corpus_matches)
154
+ if exact_source and exact_target and exact_corpus:
155
+ selected = tuple(row for row, matched in zip(transition, corpus_matches) if matched)
156
+ return RegistryMatch(MATCH_EXACT, selected, True, True, True, "Source and target contracts and corpus construction match a canonical row.", _priority_k(selected))
157
+ if exact_source and exact_target:
158
+ return RegistryMatch(MATCH_PRIOR, transition, True, True, False, "Contracts match, but the supplied corpus is new or its fingerprint is unavailable.", _priority_k(transition))
159
+ return RegistryMatch(MATCH_RELATED, transition, exact_source, exact_target, False, "A model transition matches by canonical name, but its full contract fingerprint differs.", _priority_k(transition))
160
+
161
+
162
+ def _priority_k(records: Iterable[EvidenceRecord]) -> tuple[int, ...]:
163
+ depths: set[int] = set()
164
+ for row in records:
165
+ observed = row.raw.get("observed_migration_depth")
166
+ if observed is not None:
167
+ depths.add(int(observed))
168
+ depths.update(int(k) for k in row.candidate_gap)
169
+ return tuple(sorted(depths))
170
+
171
+
172
+ def match_config(config: Any, *, corpus_fingerprint: str | None = None, corpus_name: str | None = None, corpus_size: int | None = None, records: Iterable[EvidenceRecord] | None = None) -> RegistryMatch:
173
+ """Match an ``EmbedFlowConfig`` without making network/model calls."""
174
+ from ..config import hydrate_research_contract
175
+ from .loader import load_evidence
176
+
177
+ if corpus_size is None:
178
+ docs = getattr(config, "documents", None)
179
+ if docs is not None:
180
+ corpus_size = None
181
+ source = config.source if hasattr(config, "source") else config.get("source")
182
+ target = config.target if hasattr(config, "target") else config.get("target")
183
+ # ``load_config`` already hydrates the frozen contracts. Hydrating here as
184
+ # well makes the Python API safe for callers who construct a config object
185
+ # directly, while explicit non-default contract fields remain untouched.
186
+ if hasattr(source, "model"):
187
+ source = hydrate_research_contract(source)
188
+ if hasattr(target, "model"):
189
+ target = hydrate_research_contract(target)
190
+ return match_evidence(
191
+ source_model=source,
192
+ target_model=target,
193
+ corpus_fingerprint=corpus_fingerprint,
194
+ corpus_name=corpus_name,
195
+ corpus_size=corpus_size,
196
+ records=list(records) if records is not None else load_evidence(),
197
+ )
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ REGISTRY_VERSION = "0.1.0"
11
+ SCHEMA_VERSION = "1"
12
+ MATCH_EXACT = "EXACT REGISTRY MATCH"
13
+ MATCH_PRIOR = "PRIOR EVIDENCE AVAILABLE"
14
+ MATCH_RELATED = "RELATED EVIDENCE ONLY"
15
+ MATCH_NONE = "NO REGISTRY MATCH"
16
+
17
+
18
+ class RegistryError(ValueError):
19
+ """Raised when packaged evidence is malformed or internally inconsistent."""
20
+
21
+
22
+ def _finite(value: Any, label: str) -> float:
23
+ try:
24
+ result = float(value)
25
+ except (TypeError, ValueError) as exc:
26
+ raise RegistryError(f"{label} must be a finite number") from exc
27
+ if not math.isfinite(result):
28
+ raise RegistryError(f"{label} must be finite")
29
+ return result
30
+
31
+
32
+ def _positive_integer(value: Any, label: str, *, allow_zero: bool = False) -> int:
33
+ """Parse an integer without accepting lossy values such as ``1.5``."""
34
+ if isinstance(value, bool):
35
+ raise RegistryError(f"{label} must be an integer")
36
+ try:
37
+ number = int(value)
38
+ except (TypeError, ValueError) as exc:
39
+ raise RegistryError(f"{label} must be an integer") from exc
40
+ try:
41
+ exact = float(value) == number
42
+ except (TypeError, ValueError, OverflowError):
43
+ exact = False
44
+ if not exact or (number < 0 if allow_zero else number < 1):
45
+ qualifier = "non-negative" if allow_zero else "positive"
46
+ raise RegistryError(f"{label} must be a {qualifier} integer")
47
+ return number
48
+
49
+
50
+ def contract_fingerprint(contract: Mapping[str, Any]) -> str:
51
+ """Hash the behaviorally relevant embedding contract deterministically."""
52
+ required = {
53
+ # Keep the key names identical to ``ModelConfig.fingerprint``. This
54
+ # lets registry matching prove that a packaged row and a runtime
55
+ # cache use the same semantic contract, rather than merely the same
56
+ # model display name.
57
+ "model": contract.get("canonical_model_id", contract.get("model", "")),
58
+ "revision": contract.get("revision"),
59
+ "dimension": contract.get("dimension"),
60
+ "max_length": contract.get("max_length"),
61
+ "pooling": contract.get("pooling"),
62
+ "padding_side": contract.get("padding_side"),
63
+ "truncation_side": contract.get("truncation_side"),
64
+ "query_instruction": contract.get("query_instruction", ""),
65
+ "document_instruction": contract.get("document_instruction", ""),
66
+ "normalization": contract.get("normalization"),
67
+ "dtype": contract.get("dtype", "float32"),
68
+ }
69
+ return hashlib.sha256(json.dumps(required, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class EvidenceRecord:
74
+ """Validated view of one canonical migration evidence row."""
75
+
76
+ raw: dict[str, Any]
77
+
78
+ @property
79
+ def evidence_id(self) -> str:
80
+ return str(self.raw["evidence_id"])
81
+
82
+ @property
83
+ def source(self) -> Mapping[str, Any]:
84
+ return self.raw["source"]
85
+
86
+ @property
87
+ def target(self) -> Mapping[str, Any]:
88
+ return self.raw["target"]
89
+
90
+ @property
91
+ def dataset(self) -> Mapping[str, Any]:
92
+ return self.raw["dataset"]
93
+
94
+ @property
95
+ def candidate_gap(self) -> dict[int, float]:
96
+ return {int(k): float(v) for k, v in self.raw.get("candidate_gap", {}).items()}
97
+
98
+ @property
99
+ def containment(self) -> dict[int, float]:
100
+ return {int(k): float(v) for k, v in self.raw.get("containment", {}).items()}
101
+
102
+ @property
103
+ def epsilon(self) -> float:
104
+ return float(self.raw.get("epsilon", 0.01))
105
+
106
+ def to_dict(self) -> dict[str, Any]:
107
+ return json.loads(json.dumps(self.raw, sort_keys=True))
108
+
109
+ @classmethod
110
+ def from_dict(cls, raw: Mapping[str, Any]) -> EvidenceRecord:
111
+ if not isinstance(raw, Mapping):
112
+ raise RegistryError("evidence row must be an object")
113
+ try:
114
+ row = json.loads(json.dumps(dict(raw), ensure_ascii=False))
115
+ except (TypeError, ValueError) as exc:
116
+ raise RegistryError("evidence row must contain JSON-serializable values") from exc
117
+ for key in ("registry_version", "evidence_id", "source", "target", "dataset", "metric", "provenance"):
118
+ if key not in row:
119
+ raise RegistryError(f"evidence row missing {key!r}")
120
+ if str(row["registry_version"]) != REGISTRY_VERSION:
121
+ raise RegistryError(f"{row['evidence_id']}: unsupported registry_version {row['registry_version']!r}")
122
+ for side in ("source", "target"):
123
+ contract = row[side]
124
+ if not isinstance(contract, dict):
125
+ raise RegistryError(f"{row['evidence_id']}: {side} contract must be an object")
126
+ if not str(contract.get("canonical_model_id", "")).strip():
127
+ raise RegistryError(f"{row['evidence_id']}: {side}.canonical_model_id is required")
128
+ stored = contract.get("contract_fingerprint")
129
+ if not stored:
130
+ raise RegistryError(f"{row['evidence_id']}: {side}.contract_fingerprint is required")
131
+ expected = contract_fingerprint(contract)
132
+ if str(stored) != expected:
133
+ raise RegistryError(f"{row['evidence_id']}: {side} contract fingerprint does not match contract fields")
134
+ if contract.get("dimension") is not None:
135
+ _positive_integer(contract["dimension"], f"{row['evidence_id']}: {side}.dimension")
136
+ dataset = row["dataset"]
137
+ if not isinstance(dataset, dict) or not str(dataset.get("name", "")).strip():
138
+ raise RegistryError(f"{row['evidence_id']}: dataset.name is required")
139
+ for key in ("corpus_size", "query_count"):
140
+ if dataset.get(key) is not None:
141
+ _positive_integer(dataset[key], f"{row['evidence_id']}: dataset.{key}", allow_zero=True)
142
+ metric = str(row["metric"])
143
+ if metric.lower() not in {"ndcg@10", "ndcg@k"}:
144
+ raise RegistryError(f"{row['evidence_id']}: unsupported metric {metric!r}")
145
+ epsilon = _finite(row.get("epsilon", 0.01), f"{row['evidence_id']}.epsilon")
146
+ if epsilon < 0:
147
+ raise RegistryError(f"{row['evidence_id']}: epsilon cannot be negative")
148
+ gaps = row.get("candidate_gap", {}) or {}
149
+ if not isinstance(gaps, dict):
150
+ raise RegistryError(f"{row['evidence_id']}: candidate_gap must be an object")
151
+ depths = []
152
+ for key, value in gaps.items():
153
+ try:
154
+ depth = _positive_integer(key, f"{row['evidence_id']}: candidate depth {key!r}")
155
+ except RegistryError as exc:
156
+ raise RegistryError(f"{row['evidence_id']}: invalid candidate depth {key!r}") from exc
157
+ if str(depth) != str(key):
158
+ raise RegistryError(f"{row['evidence_id']}: candidate depths must be positive canonical integers")
159
+ _finite(value, f"{row['evidence_id']}.candidate_gap[{key}]")
160
+ depths.append(depth)
161
+ if len(depths) != len(set(depths)):
162
+ raise RegistryError(f"{row['evidence_id']}: candidate_gap depths must be unique")
163
+ containment = row.get("containment", {}) or {}
164
+ if not isinstance(containment, dict):
165
+ raise RegistryError(f"{row['evidence_id']}: containment must be an object")
166
+ for key, value in containment.items():
167
+ try:
168
+ containment_depth = _positive_integer(key, f"{row['evidence_id']}: containment depth {key!r}")
169
+ except RegistryError as exc:
170
+ raise RegistryError(f"{row['evidence_id']}: containment has invalid K={key!r}") from exc
171
+ if containment_depth not in depths:
172
+ raise RegistryError(f"{row['evidence_id']}: containment has unknown K={key}")
173
+ fraction = _finite(value, f"{row['evidence_id']}.containment[{key}]")
174
+ if not 0.0 <= fraction <= 1.0:
175
+ raise RegistryError(f"{row['evidence_id']}: containment must be in [0, 1]")
176
+ declared_depths = row.get("candidate_depths")
177
+ if declared_depths is not None:
178
+ if not isinstance(declared_depths, list):
179
+ raise RegistryError(f"{row['evidence_id']}: candidate_depths must be a list")
180
+ parsed_declared = [_positive_integer(value, f"{row['evidence_id']}: candidate_depths item") for value in declared_depths]
181
+ if parsed_declared != sorted(set(parsed_declared)):
182
+ raise RegistryError(f"{row['evidence_id']}: candidate_depths must be sorted and unique")
183
+ if parsed_declared != sorted(depths):
184
+ raise RegistryError(f"{row['evidence_id']}: candidate_depths disagrees with candidate_gap keys")
185
+ observed = row.get("observed_migration_depth")
186
+ if observed is not None:
187
+ observed = _positive_integer(observed, f"{row['evidence_id']}: observed migration depth")
188
+ if observed not in depths:
189
+ raise RegistryError(f"{row['evidence_id']}: observed migration depth is not in candidate_gap")
190
+ expected = next((k for k in sorted(depths) if float(gaps[str(k)]) <= epsilon), None)
191
+ if expected != observed:
192
+ raise RegistryError(f"{row['evidence_id']}: observed migration depth disagrees with candidate_gap and epsilon")
193
+ ci = row.get("ci_certified_migration_depth")
194
+ if ci is not None and _positive_integer(ci, f"{row['evidence_id']}: CI-certified depth") not in depths:
195
+ raise RegistryError(f"{row['evidence_id']}: CI-certified depth is not in candidate_gap")
196
+ provenance = row["provenance"]
197
+ if not isinstance(provenance, dict) or not str(provenance.get("artifact", "")).strip():
198
+ raise RegistryError(f"{row['evidence_id']}: provenance.artifact is required")
199
+ digest = provenance.get("artifact_sha256")
200
+ if digest is not None and (not isinstance(digest, str) or len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest.lower())):
201
+ raise RegistryError(f"{row['evidence_id']}: provenance.artifact_sha256 must be a SHA-256 digest")
202
+ return cls(row)
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class BenchmarkProfile:
207
+ """Measured latency/throughput profile kept separate from compatibility evidence."""
208
+
209
+ raw: dict[str, Any]
210
+
211
+ @property
212
+ def profile_id(self) -> str:
213
+ return str(self.raw["profile_id"])
214
+
215
+ def to_dict(self) -> dict[str, Any]:
216
+ return json.loads(json.dumps(self.raw, sort_keys=True))
217
+
218
+ @classmethod
219
+ def from_dict(cls, raw: Mapping[str, Any]) -> BenchmarkProfile:
220
+ if not isinstance(raw, Mapping):
221
+ raise RegistryError("benchmark profile must be an object")
222
+ try:
223
+ row = json.loads(json.dumps(dict(raw), ensure_ascii=False))
224
+ except (TypeError, ValueError) as exc:
225
+ raise RegistryError("benchmark profile must contain JSON-serializable values") from exc
226
+ for key in ("registry_version", "profile_id", "kind", "provenance", "measurements"):
227
+ if key not in row:
228
+ raise RegistryError(f"benchmark profile missing {key!r}")
229
+ if not str(row["profile_id"]).strip():
230
+ raise RegistryError("benchmark profile profile_id must be non-empty")
231
+ if not str(row["kind"]).strip():
232
+ raise RegistryError(f"{row['profile_id']}: kind must be non-empty")
233
+ if str(row["registry_version"]) != REGISTRY_VERSION:
234
+ raise RegistryError(f"{row['profile_id']}: unsupported registry_version")
235
+ if not isinstance(row["measurements"], dict):
236
+ raise RegistryError(f"{row['profile_id']}: measurements must be an object")
237
+ if not isinstance(row["provenance"], dict) or not row["provenance"].get("artifact"):
238
+ raise RegistryError(f"{row['profile_id']}: provenance.artifact is required")
239
+ digest = row["provenance"].get("artifact_sha256")
240
+ if digest is not None and (not isinstance(digest, str) or len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest.lower())):
241
+ raise RegistryError(f"{row['profile_id']}: provenance.artifact_sha256 must be a SHA-256 digest")
242
+
243
+ def validate_measurement(value: Any, label: str) -> None:
244
+ if isinstance(value, Mapping):
245
+ for key, child in value.items():
246
+ validate_measurement(child, f"{label}.{key}")
247
+ return
248
+ if value is None:
249
+ return
250
+ if isinstance(value, bool):
251
+ raise RegistryError(f"{row['profile_id']}: {label} must be numeric")
252
+ try:
253
+ numeric = float(value)
254
+ except (TypeError, ValueError) as exc:
255
+ raise RegistryError(f"{row['profile_id']}: {label} must be numeric") from exc
256
+ if not math.isfinite(numeric):
257
+ raise RegistryError(f"{row['profile_id']}: {label} must be finite")
258
+
259
+ validate_measurement(row["measurements"], "measurements")
260
+ return cls(row)
261
+
262
+
263
+ def canonical_json_hash(value: Any) -> str:
264
+ """Hash a JSON value for manifest/checksum use."""
265
+ payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
266
+ return hashlib.sha256(payload).hexdigest()
embedflow/runtime.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .cache import SQLiteVectorCache
8
+ from .config import EmbedFlowConfig, load_config
9
+ from .indexes import FaissIndex, NumpyIndex, QdrantIndex
10
+ from .migration.state import DocumentStore
11
+ from .models import load_embedding_model
12
+ from .serving.engine import MigrationEngine
13
+
14
+
15
+ def load_documents(cfg: EmbedFlowConfig) -> DocumentStore:
16
+ return DocumentStore(cfg.documents.path, cfg.documents.id_field, cfg.documents.text_field)
17
+
18
+
19
+ def load_index(cfg: EmbedFlowConfig, documents: DocumentStore):
20
+ metadata = documents.documents
21
+ if cfg.index.backend.lower() == "faiss":
22
+ try:
23
+ return FaissIndex.load(cfg.index.path, ids_path=cfg.index.ids, metric=cfg.index.metric,
24
+ documents=metadata, nprobe=cfg.index.nprobe)
25
+ except (RuntimeError, ValueError) as exc:
26
+ # A NumPy fallback index may be opened on a machine that happens
27
+ # to have FAISS installed. Try the fallback for any FAISS read
28
+ # error, but preserve the original exception when the file is not
29
+ # a valid NumPy artifact either.
30
+ try:
31
+ return NumpyIndex.load(cfg.index.path, metric=cfg.index.metric, documents=metadata)
32
+ except Exception as fallback_exc:
33
+ raise exc from fallback_exc
34
+ return QdrantIndex.connect(cfg.index.path if not cfg.index.url else cfg.index.url,
35
+ cfg.index.collection, int(cfg.source.dimension or 0), documents=metadata,
36
+ metric=cfg.index.metric, api_key_env=cfg.index.api_key_env,
37
+ vector_name=cfg.index.vector_name)
38
+
39
+
40
+ def open_engine(config_path: str | Path, device: str | None = None, demo: bool = False,
41
+ start_worker: bool = True) -> MigrationEngine:
42
+ cfg = load_config(config_path)
43
+ if cfg.index.metric.lower() == "cosine" and (cfg.source.normalization.lower() != "l2" or cfg.target.normalization.lower() != "l2"):
44
+ raise ValueError("cosine index/reranking requires l2-normalized source and target vectors")
45
+ documents = load_documents(cfg)
46
+ override_device = device
47
+ source_device = override_device or cfg.source.device or cfg.target.device or "cpu"
48
+ target_device = override_device or cfg.target.device or cfg.source.device or "cpu"
49
+ source_model = None
50
+ target_model = None
51
+ source_index = None
52
+ cache = None
53
+ try:
54
+ source_model = load_embedding_model(cfg.source, model_root=Path(config_path).parent / "models", device=source_device, demo=demo)
55
+ if cfg.source.dimension and int(source_model.dimension) != int(cfg.source.dimension):
56
+ raise ValueError(f"source model dimension {source_model.dimension} != configured {cfg.source.dimension}")
57
+ source_index = load_index(cfg, documents)
58
+ if source_index.size() <= 0:
59
+ raise ValueError("legacy index is empty or its configured Qdrant collection is unavailable")
60
+ if int(source_index.dimension) != int(source_model.dimension):
61
+ raise ValueError(f"source model dimension {source_model.dimension} != existing index dimension {source_index.dimension}")
62
+ index_ids = getattr(source_index, "ids", None)
63
+ if index_ids is not None:
64
+ missing_text = sorted(set(map(str, index_ids)) - set(documents.documents))
65
+ if missing_text:
66
+ raise ValueError(f"legacy index references {len(missing_text)} documents missing from the document store (e.g. {missing_text[:3]})")
67
+ stored_fingerprint = source_index.metadata().get("model_fingerprint")
68
+ if stored_fingerprint and stored_fingerprint != source_model.fingerprint:
69
+ raise ValueError("source model fingerprint does not match the existing index contract")
70
+ target_model = load_embedding_model(cfg.target, model_root=Path(config_path).parent / "models", device=target_device, demo=demo)
71
+ if cfg.target.dimension and int(target_model.dimension) != int(cfg.target.dimension):
72
+ raise ValueError(f"target model dimension {target_model.dimension} != configured {cfg.target.dimension}")
73
+ cache = SQLiteVectorCache(cfg.cache.path, target_model.fingerprint, target_model.dimension)
74
+ probe = {}
75
+ probe_path = Path(cfg.state_path).with_name("probe_result.json")
76
+ if probe_path.exists():
77
+ try:
78
+ loaded_probe = json.loads(probe_path.read_text())
79
+ except (OSError, json.JSONDecodeError) as exc:
80
+ raise ValueError(f"invalid probe result {probe_path}") from exc
81
+ if not isinstance(loaded_probe, dict):
82
+ raise ValueError(f"probe result {probe_path} must contain a JSON object")
83
+ probe = loaded_probe
84
+ return MigrationEngine(cfg, source_model, target_model, source_index, cache, documents,
85
+ probe=probe, ann_status="UNKNOWN", start_worker=start_worker)
86
+ except Exception:
87
+ if cache is not None:
88
+ cache.close()
89
+ if target_model is not None:
90
+ target_model.close()
91
+ if source_model is not None:
92
+ source_model.close()
93
+ if source_index is not None:
94
+ try:
95
+ close_index = getattr(source_index, "close", None)
96
+ if callable(close_index):
97
+ close_index()
98
+ except Exception:
99
+ pass
100
+ raise
101
+
102
+
103
+ def build_faiss_from_documents(cfg: EmbedFlowConfig, model: Any, documents: DocumentStore,
104
+ path: str | Path | None = None, nlist: int | None = None):
105
+ vectors = model.encode_documents(list(documents.documents.values()), batch_size=32)
106
+ target = path or cfg.index.path
107
+ try:
108
+ return FaissIndex.build(vectors, list(documents.documents), path=target,
109
+ metric=cfg.index.metric, nlist=nlist, ids_path=cfg.index.ids, documents=documents.documents,
110
+ metadata={"model_fingerprint": model.fingerprint, "model_id": model.model_id})
111
+ except RuntimeError as exc:
112
+ if "FAISS" not in str(exc): raise
113
+ return NumpyIndex.build(vectors, list(documents.documents), path=target, metric=cfg.index.metric, ids_path=cfg.index.ids,
114
+ documents=documents.documents,
115
+ metadata={"model_fingerprint": model.fingerprint, "model_id": model.model_id})
@@ -0,0 +1,3 @@
1
+ from .engine import MigrationEngine
2
+
3
+ __all__ = ["MigrationEngine"]