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,102 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from collections.abc import Mapping, Sequence
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .candidate_gap import CandidateGapCurve, observed_k_epsilon
10
+
11
+
12
+ def write_curve_csv(path: str | Path, curve: Sequence[CandidateGapCurve], *, field: str = "candidate_gap") -> None:
13
+ path = Path(path)
14
+ path.parent.mkdir(parents=True, exist_ok=True)
15
+ rows = [point.to_dict() for point in curve]
16
+ with path.open("w", newline="") as handle:
17
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]) if rows else ["k", field])
18
+ writer.writeheader()
19
+ writer.writerows(rows)
20
+
21
+
22
+ def migration_report(
23
+ *,
24
+ source_model: str,
25
+ target_model: str,
26
+ corpus_size: int,
27
+ curve: Sequence[CandidateGapCurve],
28
+ diagnostic: str | None = None,
29
+ recommended_k: int | None = None,
30
+ ann_status: str = "UNKNOWN",
31
+ t2_warning: str = "T2-v1 is an empirical finite-tail diagnostic, not a compatibility guarantee.",
32
+ epsilon: float = 0.01,
33
+ native_target_index_used: bool = True,
34
+ ) -> dict[str, Any]:
35
+ observed = observed_k_epsilon(curve, epsilon=epsilon) if native_target_index_used else None
36
+ if str(diagnostic or "").upper() == "SAFE":
37
+ recommendation = "Progressive migration is a reasonable candidate for further deployment validation."
38
+ elif str(diagnostic or "").upper() == "EXPAND":
39
+ recommendation = "Expand source retrieval depth and validate manually before deployment."
40
+ else:
41
+ recommendation = "Prefer manual validation, a larger probe, or a full target backfill before relying on progressive migration."
42
+ return {
43
+ "schema_version": "0.1",
44
+ "source_model": source_model,
45
+ "target_model": target_model,
46
+ "corpus_documents": int(corpus_size),
47
+ "diagnostic": diagnostic,
48
+ "recommended_initial_k": recommended_k,
49
+ "observed_k_epsilon": observed,
50
+ "epsilon": float(epsilon),
51
+ "ann_status": ann_status,
52
+ "recommendation": recommendation,
53
+ "native_target_index_used": bool(native_target_index_used),
54
+ "t2_warning": t2_warning,
55
+ "candidate_gap_curve": [point.to_dict() for point in curve],
56
+ "limitations": [
57
+ "Candidate gap and containment are separate metrics.",
58
+ "A SAFE T2-v1 diagnostic is empirical and is not a compatibility guarantee.",
59
+ "ANN fidelity is UNKNOWN unless an exact/reference source comparison was supplied.",
60
+ ],
61
+ }
62
+
63
+
64
+ def write_report(path: str | Path, report: Mapping[str, Any]) -> None:
65
+ path = Path(path)
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ path.write_text(json.dumps(dict(report), indent=2, ensure_ascii=False, default=float) + "\n")
68
+
69
+
70
+ def report_markdown(report: Mapping[str, Any]) -> str:
71
+ curve = list(report.get("candidate_gap_curve", []))
72
+ corpus_value = report.get("corpus_documents", "unknown")
73
+ try:
74
+ corpus_display = f"{int(corpus_value):,}"
75
+ except (TypeError, ValueError):
76
+ corpus_display = str(corpus_value)
77
+ lines = [
78
+ "# EmbedFlow Migration Report", "", "## Models", "",
79
+ f"- Source: `{report.get('source_model', 'unknown')}`",
80
+ f"- Target: `{report.get('target_model', 'unknown')}`",
81
+ f"- Corpus documents: `{corpus_display}`",
82
+ "", "## Decision", "",
83
+ f"- T2-v1 diagnostic: **{report.get('diagnostic') or 'not run'}**",
84
+ f"- Recommended initial candidate depth: **K={report.get('recommended_initial_k') or 'n/a'}**",
85
+ f"- Observed K*_epsilon: **{report.get('observed_k_epsilon') or 'not computed'}** (epsilon={report.get('epsilon', 0.01)})",
86
+ f"- Finite-tail behavior: **{report.get('finite_tail_behavior', 'not established')}**",
87
+ f"- ANN health: **{report.get('ann_status', 'UNKNOWN')}**",
88
+ f"- Recommendation: {report.get('recommendation', 'Further validation required.')}",
89
+ "", "> SAFE is an empirical finite-tail diagnostic, not a compatibility guarantee.",
90
+ "", "## Candidate gap curve", "", "| K | native target | target within source candidates | G(K) | containment |", "|---:|---:|---:|---:|---:|",
91
+ ]
92
+ for row in curve:
93
+ lines.append(f"| {row.get('k')} | {float(row.get('native_target_quality', 0)):.4f} | {float(row.get('restricted_target_quality', 0)):.4f} | {float(row.get('candidate_gap', 0)):.4f} | {float(row.get('containment', 0)):.4f} |")
94
+ reused = report.get("registry_reused_values") or []
95
+ if reused:
96
+ lines.extend(["", "## Reused registry evidence", "", "> These are canonical prior measurements reused because the source/target contracts and corpus construction matched exactly. They are not a new-corpus probe result.", "", "| Evidence | Dataset | G(50) | Observed K* | CI-certified K* |", "|---|---|---:|---:|---:|"])
97
+ for item in reused:
98
+ gaps = item.get("candidate_gap") or {}
99
+ gap_50 = gaps.get("50", gaps.get(50, "unavailable"))
100
+ lines.append(f"| `{item.get('evidence_id', 'unknown')}` | {item.get('dataset', 'unknown')} | {float(gap_50):.5f} | {item.get('observed_migration_depth') or 'unavailable'} | {item.get('ci_certified_migration_depth') or 'unavailable'} |" if gap_50 != "unavailable" else f"| `{item.get('evidence_id', 'unknown')}` | {item.get('dataset', 'unknown')} | unavailable | {item.get('observed_migration_depth') or 'unavailable'} | {item.get('ci_certified_migration_depth') or 'unavailable'} |")
101
+ lines.extend(["", "## Limitations", "", *[f"- {item}" for item in report.get("limitations", [])], ""])
102
+ return "\n".join(lines)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class T2Diagnostic:
11
+ """A frozen finite-tail diagnostic, never a compatibility guarantee."""
12
+
13
+ diagnostic: str
14
+ features: dict[str, float]
15
+ implementation: str
16
+ warning: str = "T2-v1 is an empirical finite-tail diagnostic, not a compatibility guarantee."
17
+
18
+ def to_dict(self) -> dict[str, Any]:
19
+ return {
20
+ "diagnostic": self.diagnostic,
21
+ "features": self.features,
22
+ "implementation": self.implementation,
23
+ "warning": self.warning,
24
+ }
25
+
26
+
27
+ def diagnose_t2(features: Mapping[str, Any], root: str | Path | None = None) -> T2Diagnostic:
28
+ """Run the repository's frozen T2-v1 rule without reimplementing it."""
29
+ from src.t2_v1 import decide, verify_t2_hash
30
+
31
+ if not isinstance(features, Mapping):
32
+ raise TypeError("T2-v1 features must be a mapping")
33
+ forbidden = ("qrel", "native", "ndcg", "candidate_gap", "g(", "target_rank", "label", "observed_k")
34
+ leaked = [str(key) for key in features if any(token in str(key).lower() for token in forbidden)]
35
+ if leaked:
36
+ raise ValueError(f"T2-v1 refuses label/native-target data in features: {leaked}")
37
+ required = {
38
+ "probe_residual_tail_50_mean", "deepest_p90", "late_tail_area",
39
+ "stability_to_500_50_mean", "last_shell_any_rate", "fraction_margin_nonpositive",
40
+ }
41
+ missing = sorted(required.difference(features))
42
+ if missing:
43
+ raise ValueError(f"T2-v1 feature row is missing required fields: {missing}")
44
+
45
+ if root is None:
46
+ root = Path(__file__).resolve().parents[2]
47
+ if not (Path(root) / "frozen").exists():
48
+ root = Path(__file__).resolve().parents[1]
49
+ root = Path(root)
50
+ frozen = root / "frozen" / "T2_V1_FROZEN_SPEC.md"
51
+ if frozen.exists():
52
+ verify_t2_hash(root)
53
+ scalar = {}
54
+ for key, value in features.items():
55
+ if isinstance(value, bool):
56
+ raise ValueError(f"T2-v1 feature {key!r} must be numeric, not boolean")
57
+ try:
58
+ scalar[str(key)] = float(value)
59
+ except (TypeError, ValueError) as exc:
60
+ raise ValueError(f"T2-v1 feature {key!r} must be numeric") from exc
61
+ import math
62
+ if not all(math.isfinite(value) for value in scalar.values()):
63
+ raise ValueError("T2-v1 features must be finite")
64
+ return T2Diagnostic(decide(scalar), scalar, "src.t2_v1.decide")
embedflow/config.py ADDED
@@ -0,0 +1,455 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ _UNSET = object()
13
+
14
+
15
+ def _integer(value: Any, label: str, *, minimum: int | None = None) -> int:
16
+ """Validate integer-valued YAML fields without lossy coercion."""
17
+ if isinstance(value, bool):
18
+ raise ValueError(f"{label} must be an integer")
19
+ try:
20
+ parsed = int(value)
21
+ except (TypeError, ValueError, OverflowError) as exc:
22
+ raise ValueError(f"{label} must be an integer") from exc
23
+ try:
24
+ exact = float(value) == parsed
25
+ except (TypeError, ValueError, OverflowError):
26
+ exact = False
27
+ if not exact or (minimum is not None and parsed < minimum):
28
+ qualifier = f" >= {minimum}" if minimum is not None else ""
29
+ raise ValueError(f"{label} must be an integer{qualifier}")
30
+ return parsed
31
+
32
+
33
+ @dataclass(init=False)
34
+ class ModelConfig:
35
+ model: str
36
+ revision: str | None = None
37
+ dimension: int | None = None
38
+ max_length: int = 512
39
+ pooling: str = "mean_tokens"
40
+ padding_side: str = "right"
41
+ truncation_side: str = "right"
42
+ query_instruction: str = ""
43
+ document_instruction: str = ""
44
+ normalization: str = "l2"
45
+ dtype: str = "float32"
46
+ local_path: str | None = None
47
+ device: str | None = None
48
+
49
+ def __init__(
50
+ self,
51
+ model: str,
52
+ revision: str | None | object = _UNSET,
53
+ dimension: int | None | object = _UNSET,
54
+ max_length: int | object = _UNSET,
55
+ pooling: str | object = _UNSET,
56
+ padding_side: str | object = _UNSET,
57
+ truncation_side: str | object = _UNSET,
58
+ query_instruction: str | object = _UNSET,
59
+ document_instruction: str | object = _UNSET,
60
+ normalization: str | object = _UNSET,
61
+ dtype: str | object = _UNSET,
62
+ local_path: str | None | object = _UNSET,
63
+ device: str | None | object = _UNSET,
64
+ ) -> None:
65
+ """Construct a model contract while remembering explicit overrides.
66
+
67
+ The research contracts have non-generic defaults (for example,
68
+ Qwen3-8B uses length 8192 while the product's generic default is
69
+ 512). A normal dataclass cannot distinguish an omitted value from a
70
+ caller explicitly requesting that generic default. The sentinel
71
+ arguments let ``hydrate_research_contract`` fill only omitted fields,
72
+ preserving deliberate prompt/pooling/max-length changes for matching
73
+ and cache safety.
74
+ """
75
+ defaults = {
76
+ "revision": None,
77
+ "dimension": None,
78
+ "max_length": 512,
79
+ "pooling": "mean_tokens",
80
+ "padding_side": "right",
81
+ "truncation_side": "right",
82
+ "query_instruction": "",
83
+ "document_instruction": "",
84
+ "normalization": "l2",
85
+ "dtype": "float32",
86
+ "local_path": None,
87
+ "device": None,
88
+ }
89
+ values = {
90
+ "revision": revision,
91
+ "dimension": dimension,
92
+ "max_length": max_length,
93
+ "pooling": pooling,
94
+ "padding_side": padding_side,
95
+ "truncation_side": truncation_side,
96
+ "query_instruction": query_instruction,
97
+ "document_instruction": document_instruction,
98
+ "normalization": normalization,
99
+ "dtype": dtype,
100
+ "local_path": local_path,
101
+ "device": device,
102
+ }
103
+ self.model = str(model)
104
+ self._explicit_fields = {key for key, value in values.items() if value is not _UNSET}
105
+ for key, value in values.items():
106
+ setattr(self, key, defaults[key] if value is _UNSET else value)
107
+
108
+ def contract(self) -> dict[str, Any]:
109
+ return asdict(self)
110
+
111
+ @property
112
+ def fingerprint(self) -> str:
113
+ # Paths and execution devices are deployment details, not embedding
114
+ # semantics. Excluding them keeps a cache/index portable across
115
+ # machines while retaining model revision, prompts, pooling, padding,
116
+ # truncation, normalization, dtype, and dimension in the contract.
117
+ semantic = {key: value for key, value in self.contract().items() if key not in {"local_path", "device"}}
118
+ payload = json.dumps(semantic, sort_keys=True, separators=(",", ":"))
119
+ return hashlib.sha256(payload.encode()).hexdigest()
120
+
121
+
122
+ @dataclass
123
+ class IndexConfig:
124
+ backend: str = "faiss"
125
+ path: str = "./legacy.index"
126
+ metric: str = "cosine"
127
+ nprobe: int = 64
128
+ collection: str = "embedflow"
129
+ url: str | None = None
130
+ ids: str | None = None
131
+ vector_name: str | None = None
132
+ api_key_env: str | None = "QDRANT_API_KEY"
133
+
134
+
135
+ @dataclass
136
+ class DocumentsConfig:
137
+ path: str = "./documents.jsonl"
138
+ id_field: str = "id"
139
+ text_field: str = "text"
140
+
141
+
142
+ @dataclass
143
+ class MigrationConfig:
144
+ candidate_depth: int | str = 50
145
+ kmax_probe: int = 500
146
+ probe_queries: int = 100
147
+ max_sync_misses: int = 4
148
+ background_batch_size: int = 32
149
+ max_retries: int = 3
150
+ worker_count: int = 1
151
+
152
+
153
+ @dataclass
154
+ class CacheConfig:
155
+ path: str = "./embedflow_cache"
156
+
157
+
158
+ @dataclass
159
+ class EconomicsConfig:
160
+ gpu_price_per_hour: float | None = None
161
+ target_docs_per_second: float | None = None
162
+
163
+
164
+ @dataclass
165
+ class ProbeConfig:
166
+ """Settings for the leakage-safe finite-pool compatibility probe."""
167
+
168
+ queries: str | None = None
169
+ k_values: list[int] = field(default_factory=lambda: [10, 20, 50, 100, 200, 500])
170
+ kmax: int = 500
171
+ epsilon: float = 0.01
172
+ seed: int = 42
173
+ limit: int | None = None
174
+
175
+
176
+ @dataclass
177
+ class TelemetryConfig:
178
+ latency_log: str = "./logs/latency.jsonl"
179
+
180
+
181
+ @dataclass
182
+ class EmbedFlowConfig:
183
+ source: ModelConfig
184
+ target: ModelConfig
185
+ index: IndexConfig = field(default_factory=IndexConfig)
186
+ documents: DocumentsConfig = field(default_factory=DocumentsConfig)
187
+ migration: MigrationConfig = field(default_factory=MigrationConfig)
188
+ cache: CacheConfig = field(default_factory=CacheConfig)
189
+ economics: EconomicsConfig = field(default_factory=EconomicsConfig)
190
+ probe: ProbeConfig = field(default_factory=ProbeConfig)
191
+ telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
192
+ state_path: str = "./embedflow_state.json"
193
+ dashboard_title: str = "EmbedFlow — Progressive Embedding Migration"
194
+
195
+ def resolve_paths(self, base: Path) -> EmbedFlowConfig:
196
+ """Resolve relative paths against the configuration file directory."""
197
+ for obj, attr in ((self.index, "path"), (self.documents, "path"),
198
+ (self.cache, "path"), (self, "state_path"),
199
+ (self.telemetry, "latency_log")):
200
+ raw_value = str(getattr(obj, attr))
201
+ # A Qdrant URL is a connection endpoint, not a filesystem path.
202
+ # Leave it untouched so ``index: {backend: qdrant, path: https://…}``
203
+ # works even when no separate ``url`` field is supplied.
204
+ if "://" in raw_value:
205
+ continue
206
+ value = Path(raw_value)
207
+ if not value.is_absolute():
208
+ setattr(obj, attr, str((base / value).resolve()))
209
+ if self.source.local_path:
210
+ p = Path(self.source.local_path)
211
+ self.source.local_path = str((base / p).resolve()) if not p.is_absolute() else str(p)
212
+ if self.target.local_path:
213
+ p = Path(self.target.local_path)
214
+ self.target.local_path = str((base / p).resolve()) if not p.is_absolute() else str(p)
215
+ if self.probe.queries:
216
+ p = Path(self.probe.queries)
217
+ self.probe.queries = str((base / p).resolve()) if not p.is_absolute() else str(p)
218
+ if self.index.ids:
219
+ p = Path(self.index.ids)
220
+ self.index.ids = str((base / p).resolve()) if not p.is_absolute() else str(p)
221
+ return self
222
+
223
+ def to_dict(self) -> dict[str, Any]:
224
+ return asdict(self)
225
+
226
+ def validate(self) -> None:
227
+ for label, model in (("source", self.source), ("target", self.target)):
228
+ if not model.model.strip():
229
+ raise ValueError(f"{label}.model is required")
230
+ if model.dimension is not None:
231
+ model.dimension = _integer(model.dimension, f"{label}.dimension", minimum=1)
232
+ model.max_length = _integer(model.max_length, f"{label}.max_length", minimum=1)
233
+ if not isinstance(model.normalization, str) or model.normalization.lower() not in {"l2", "none", "identity"}:
234
+ raise ValueError(f"{label}.normalization must be l2, none, or identity")
235
+ if self.source.fingerprint == self.target.fingerprint:
236
+ raise ValueError("source and target embedding contracts must differ for migration")
237
+ if not isinstance(self.index.backend, str) or self.index.backend.lower() not in {"faiss", "qdrant"}:
238
+ raise ValueError("index.backend must be faiss or qdrant")
239
+ if not isinstance(self.index.metric, str) or self.index.metric.lower() not in {"cosine", "dot", "inner_product"}:
240
+ raise ValueError("index.metric must be cosine, dot, or inner_product")
241
+ candidate_depth = self.migration.candidate_depth
242
+ if isinstance(candidate_depth, str) and candidate_depth.strip().lower() == "auto":
243
+ candidate_depth = "auto"
244
+ self.migration.candidate_depth = "auto"
245
+ if candidate_depth != "auto":
246
+ candidate_depth = _integer(candidate_depth, "migration.candidate_depth", minimum=1)
247
+ self.migration.candidate_depth = candidate_depth
248
+ self.migration.kmax_probe = _integer(self.migration.kmax_probe, "migration.kmax_probe", minimum=1)
249
+ self.migration.probe_queries = _integer(self.migration.probe_queries, "migration.probe_queries", minimum=1)
250
+ if candidate_depth != "auto" and self.migration.kmax_probe < int(candidate_depth):
251
+ raise ValueError("migration.kmax_probe must be >= candidate_depth")
252
+ self.migration.max_sync_misses = _integer(self.migration.max_sync_misses, "migration.max_sync_misses", minimum=0)
253
+ self.migration.background_batch_size = _integer(self.migration.background_batch_size, "migration.background_batch_size", minimum=1)
254
+ self.migration.max_retries = _integer(self.migration.max_retries, "migration.max_retries", minimum=1)
255
+ self.migration.worker_count = _integer(self.migration.worker_count, "migration.worker_count", minimum=1)
256
+ self.index.nprobe = _integer(self.index.nprobe, "index.nprobe", minimum=1)
257
+ self.probe.kmax = _integer(self.probe.kmax, "probe.kmax", minimum=10)
258
+ if self.probe.kmax < 10:
259
+ raise ValueError("probe.kmax must be at least 10")
260
+ if not self.probe.k_values:
261
+ raise ValueError("probe.k_values must contain positive integers")
262
+ try:
263
+ self.probe.k_values = [_integer(k, "probe.k_values item", minimum=1) for k in self.probe.k_values]
264
+ except ValueError as exc:
265
+ raise ValueError("probe.k_values must contain positive integers") from exc
266
+ if len(set(self.probe.k_values)) != len(self.probe.k_values):
267
+ raise ValueError("probe.k_values must not contain duplicates")
268
+ if any(k > int(self.probe.kmax) for k in self.probe.k_values):
269
+ raise ValueError("probe.k_values cannot exceed probe.kmax")
270
+ self.probe.epsilon = float(self.probe.epsilon)
271
+ if not math.isfinite(self.probe.epsilon) or self.probe.epsilon < 0:
272
+ raise ValueError("probe.epsilon must be finite and non-negative")
273
+
274
+
275
+ def _model(raw: dict[str, Any], fallback: str) -> ModelConfig:
276
+ raw = dict(raw or {})
277
+ model = raw.pop("model", raw.pop("model_id", fallback))
278
+ # Accept the research project's names and hydrate their exact contracts.
279
+ return ModelConfig(model=str(model), **raw)
280
+
281
+
282
+ def from_dict(raw: dict[str, Any]) -> EmbedFlowConfig:
283
+ if not isinstance(raw, Mapping):
284
+ raise ValueError("configuration root must be a YAML object")
285
+ raw = dict(raw or {})
286
+ source = hydrate_research_contract(_model(raw.get("source", {}), ""))
287
+ target = hydrate_research_contract(_model(raw.get("target", {}), ""))
288
+ migration_raw = dict(raw.get("migration", {}))
289
+ if isinstance(migration_raw.get("candidate_depth"), str) and migration_raw["candidate_depth"].strip().lower() == "auto":
290
+ migration_raw["candidate_depth"] = "auto"
291
+ cfg = EmbedFlowConfig(
292
+ source=source,
293
+ target=target,
294
+ index=IndexConfig(**dict(raw.get("index", {}))),
295
+ documents=DocumentsConfig(**dict(raw.get("documents", {}))),
296
+ migration=MigrationConfig(**migration_raw),
297
+ cache=CacheConfig(**dict(raw.get("cache", {}))),
298
+ economics=EconomicsConfig(**dict(raw.get("economics", {}))),
299
+ probe=ProbeConfig(**dict(raw.get("probe", {}))),
300
+ telemetry=TelemetryConfig(**dict(raw.get("telemetry", {}))),
301
+ state_path=str(raw.get("state_path", "./embedflow_state.json")),
302
+ dashboard_title=str(raw.get("dashboard_title", EmbedFlowConfig.__dataclass_fields__["dashboard_title"].default)),
303
+ )
304
+ cfg.validate()
305
+ return cfg
306
+
307
+
308
+ def hydrate_research_contract(model: ModelConfig, project_root: Path | None = None) -> ModelConfig:
309
+ """Use the frozen research contract for one of its registered model keys."""
310
+ # Keep the public package self-contained. The research checkout also has
311
+ # these contracts in ``config/nq_5090_1m.yaml``; the built-ins ensure that
312
+ # installing EmbedFlow from a clean Git clone still preserves the exact
313
+ # pooling, prompt, padding, and revision semantics for known checkpoints.
314
+ builtin = {
315
+ "sentence-transformers/all-MiniLM-L6-v2": {
316
+ "revision": "1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
317
+ "dimension": 384, "max_length": 512, "pooling": "mean_tokens",
318
+ "padding_side": "right", "truncation_side": "right",
319
+ "query_instruction": "", "document_instruction": "", "normalization": "l2",
320
+ },
321
+ "Qwen/Qwen3-Embedding-0.6B": {
322
+ "revision": "97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3",
323
+ "dimension": 1024, "max_length": 512, "pooling": "last_non_padding_token",
324
+ "padding_side": "left", "truncation_side": "right",
325
+ "query_instruction": "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: {text}",
326
+ "document_instruction": "", "normalization": "l2",
327
+ },
328
+ "Qwen/Qwen3-Embedding-4B": {
329
+ "revision": "5cf2132abc99cad020ac570b19d031efec650f2b",
330
+ "dimension": 2560, "max_length": 8192, "pooling": "last_non_padding_token",
331
+ "padding_side": "left", "truncation_side": "right",
332
+ "query_instruction": "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:{text}",
333
+ "document_instruction": "", "normalization": "l2",
334
+ },
335
+ "Qwen/Qwen3-Embedding-8B": {
336
+ "revision": "1d8ad4ca9b3dd8059ad90a75d4983776a23d44af",
337
+ "dimension": 4096, "max_length": 8192, "pooling": "last_non_padding_token",
338
+ "padding_side": "left", "truncation_side": "right",
339
+ "query_instruction": "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:{text}",
340
+ "document_instruction": "", "normalization": "l2",
341
+ },
342
+ }
343
+ if model.model in builtin:
344
+ for field_name, value in builtin[model.model].items():
345
+ # An explicit user value wins, while dataclass defaults are filled
346
+ # from the frozen contract. ``revision``/dimension are especially
347
+ # important for cache and index safety.
348
+ current = getattr(model, field_name)
349
+ default = ModelConfig.__dataclass_fields__[field_name].default
350
+ explicit = field_name in getattr(model, "_explicit_fields", set())
351
+ if not explicit and (current is None or current == default or field_name in {"query_instruction", "document_instruction"} and not current):
352
+ setattr(model, field_name, value)
353
+ try:
354
+ import yaml
355
+ root = project_root or Path(__file__).resolve().parents[1]
356
+ config_path = root / "config" / "nq_5090_1m.yaml"
357
+ if config_path.exists():
358
+ raw = yaml.safe_load(config_path.read_text()) or {}
359
+ for key, item in (raw.get("models") or {}).items():
360
+ if item.get("model_id") == model.model or key == model.model:
361
+ known = dict(item)
362
+ known.pop("min_vram_gib", None)
363
+ known.pop("dtype_preference", None)
364
+ known["model"] = known.pop("model_id", model.model)
365
+ # The research YAML calls Qwen pooling `last_token`; the
366
+ # product encoder accepts that as last_non_padding_token.
367
+ if known.get("pooling") == "last_token":
368
+ known["pooling"] = "last_non_padding_token"
369
+ for field_name in ModelConfig.__dataclass_fields__:
370
+ if field_name in known and field_name not in getattr(model, "_explicit_fields", set()):
371
+ setattr(model, field_name, known[field_name])
372
+ return model
373
+ except Exception:
374
+ # Generic Hugging Face models remain usable when the research config is
375
+ # not present. The caller can still inspect the resulting fingerprint.
376
+ pass
377
+ return model
378
+
379
+
380
+ def load_config(path: str | Path) -> EmbedFlowConfig:
381
+ path = Path(path)
382
+ if not path.exists():
383
+ raise FileNotFoundError(path)
384
+ import yaml
385
+ try:
386
+ parsed = yaml.safe_load(path.read_text())
387
+ except yaml.YAMLError as exc:
388
+ raise ValueError(f"invalid YAML configuration: {path}: {exc}") from exc
389
+ cfg = from_dict(parsed or {})
390
+ _apply_environment_overrides(cfg)
391
+ cfg.validate()
392
+ return cfg.resolve_paths(path.parent.resolve())
393
+
394
+
395
+ def _apply_environment_overrides(cfg: EmbedFlowConfig) -> None:
396
+ """Apply deliberately small, documented deployment overrides.
397
+
398
+ YAML remains the source of truth for experiments. These variables are
399
+ useful for containerized serving and secrets-free deployment manifests;
400
+ unset variables leave the file untouched.
401
+ """
402
+ paths: dict[str, tuple[Any, str]] = {
403
+ "EMBEDFLOW_SOURCE_MODEL": (cfg.source, "model"),
404
+ "EMBEDFLOW_TARGET_MODEL": (cfg.target, "model"),
405
+ "EMBEDFLOW_SOURCE_DEVICE": (cfg.source, "device"),
406
+ "EMBEDFLOW_TARGET_DEVICE": (cfg.target, "device"),
407
+ "EMBEDFLOW_INDEX_BACKEND": (cfg.index, "backend"),
408
+ "EMBEDFLOW_INDEX_PATH": (cfg.index, "path"),
409
+ "EMBEDFLOW_INDEX_URL": (cfg.index, "url"),
410
+ "EMBEDFLOW_INDEX_COLLECTION": (cfg.index, "collection"),
411
+ "EMBEDFLOW_INDEX_VECTOR_NAME": (cfg.index, "vector_name"),
412
+ "EMBEDFLOW_QDRANT_API_KEY_ENV": (cfg.index, "api_key_env"),
413
+ "EMBEDFLOW_DOCUMENTS_PATH": (cfg.documents, "path"),
414
+ "EMBEDFLOW_CACHE_PATH": (cfg.cache, "path"),
415
+ "EMBEDFLOW_STATE_PATH": (cfg, "state_path"),
416
+ "EMBEDFLOW_LATENCY_LOG": (cfg.telemetry, "latency_log"),
417
+ }
418
+ for variable, (target, field_name) in paths.items():
419
+ value = os.environ.get(variable)
420
+ if value is not None and value.strip():
421
+ setattr(target, field_name, value.strip())
422
+ candidate = os.environ.get("EMBEDFLOW_CANDIDATE_DEPTH")
423
+ if candidate:
424
+ if candidate.strip().lower() == "auto":
425
+ cfg.migration.candidate_depth = "auto"
426
+ else:
427
+ try:
428
+ parsed = int(candidate)
429
+ if float(candidate) != parsed:
430
+ raise ValueError
431
+ except (TypeError, ValueError, OverflowError) as exc:
432
+ raise ValueError("EMBEDFLOW_CANDIDATE_DEPTH must be auto or an integer") from exc
433
+ cfg.migration.candidate_depth = parsed
434
+ for variable, target, field_name in (
435
+ ("EMBEDFLOW_MAX_SYNC_MISSES", cfg.migration, "max_sync_misses"),
436
+ ("EMBEDFLOW_BACKGROUND_BATCH_SIZE", cfg.migration, "background_batch_size"),
437
+ ("EMBEDFLOW_PROBE_KMAX", cfg.probe, "kmax"),
438
+ ("EMBEDFLOW_INDEX_NPROBE", cfg.index, "nprobe"),
439
+ ):
440
+ value = os.environ.get(variable)
441
+ if value is not None and value.strip():
442
+ try:
443
+ parsed = int(value)
444
+ if float(value) != parsed:
445
+ raise ValueError
446
+ except (TypeError, ValueError, OverflowError) as exc:
447
+ raise ValueError(f"{variable} must be an integer") from exc
448
+ setattr(target, field_name, parsed)
449
+
450
+
451
+ def save_config(cfg: EmbedFlowConfig, path: str | Path) -> None:
452
+ import yaml
453
+ path = Path(path)
454
+ path.parent.mkdir(parents=True, exist_ok=True)
455
+ path.write_text(yaml.safe_dump(cfg.to_dict(), sort_keys=False))
@@ -0,0 +1 @@
1
+ """Packaged, small data assets for EmbedFlow."""
@@ -0,0 +1 @@
1
+ """Core migration evidence shipped with EmbedFlow."""
@@ -0,0 +1,3 @@
1
+ {"registry_version":"0.1.0","profile_id":"latency_25k_qwen3_4b_to_8b_k50_nprobe64_warm","kind":"serving_latency","status":"measured_workload_specific","scope":"25K-document serving benchmark","source":{"canonical_model_id":"Qwen/Qwen3-Embedding-4B","contract_fingerprint":"0607b87cc200f80bfc498e595891fb533f42677355b18279be42d7d988896c44"},"target":{"canonical_model_id":"Qwen/Qwen3-Embedding-8B","contract_fingerprint":"11d6d071a07a73b0bb3588a49447bb8bf58cb5426af732b534d374581ac4b018"},"dataset":{"name":"Natural Questions canonical 25K prefix","corpus_size":25000,"query_count":1000,"split":"latency_25k","fingerprint":null},"configuration":{"K":50,"nprobe":64,"target_nprobe":64,"cache_state":"warm","index_type":"ivf_flat"},"measurements":{"p50_ms":38.792666,"p95_ms":42.68700545,"added_vs_native_p50_ms":19.8086975,"added_vs_native_p95_ms":21.12569415,"mean_stage_ms":{"source_query_encode":19.48547872,"source_ann":0.401102655,"target_query_encode":18.742604425,"candidate_cache_lookup":0.213127613,"target_score":0.061910947,"topk":0.06689177}},"hardware":{"gpu":"unspecified in retained CSV","peak_gpu_memory_mb":37917.7666015625},"provenance":{"artifact":"remote_scale_experiment/experiments/latency/results_25k/latency_summary.csv","artifact_sha256":"13136c9efa90a569d8a771237a3f8536934ea94111a05701872a7c0ba3674416","table":"latency_summary.csv","status":"retained_external","git_commit":null},"notes":["Measured on a 25K-document workload; the retained CSV does not identify the GPU, so this row must not be labeled an H100 benchmark.","Latency is hardware- and workload-dependent; it is not a million-document latency claim."]}
2
+ {"registry_version":"0.1.0","profile_id":"latency_25k_native_qwen3_8b","kind":"serving_latency","status":"measured_workload_specific","scope":"25K-document serving benchmark","source":{"canonical_model_id":"native_target","contract_fingerprint":null},"target":{"canonical_model_id":"Qwen/Qwen3-Embedding-8B","contract_fingerprint":"11d6d071a07a73b0bb3588a49447bb8bf58cb5426af732b534d374581ac4b018"},"dataset":{"name":"Natural Questions canonical 25K prefix","corpus_size":25000,"query_count":1000,"split":"latency_25k","fingerprint":null},"configuration":{"cache_state":"not_applicable","index_type":"ivf_flat","target_nprobe":64},"measurements":{"p50_ms":18.9839685,"p95_ms":21.5613113},"hardware":{"gpu":"unspecified in retained CSV","peak_gpu_memory_mb":37917.7666015625},"provenance":{"artifact":"remote_scale_experiment/experiments/latency/results_25k/latency_summary.csv","artifact_sha256":"13136c9efa90a569d8a771237a3f8536934ea94111a05701872a7c0ba3674416","table":"latency_summary.csv","status":"retained_external","git_commit":null},"notes":["Native target baseline for the same 25K workload; hardware identity is not retained in this CSV."]}
3
+ {"registry_version":"0.1.0","profile_id":"throughput_qwen3_8b_document_encoding_profile","kind":"document_encoding_throughput","status":"measured_workload_specific","scope":"retained model-profile benchmark","source":{"canonical_model_id":"Qwen/Qwen3-Embedding-8B","contract_fingerprint":"11d6d071a07a73b0bb3588a49447bb8bf58cb5426af732b534d374581ac4b018"},"target":null,"dataset":{"name":"profile fixture (dataset details not retained)","corpus_size":null,"query_count":null,"split":null,"fingerprint":null},"configuration":{"repetitions":5,"hardware":"not recorded in profile JSON"},"measurements":{"best_rows_per_second":331.19314884980446,"best_batch_size":40,"best_mean_seconds":0.1207754452014342,"all_batch_rows_per_second":{"1":27.222915766054818,"2":54.38365517199809,"4":109.39658538220068,"8":193.1738374840373,"12":247.2968027380519,"16":262.2610551452983,"20":301.8062592513787,"24":270.37271409519764,"32":317.50042263060874,"40":331.19314884980446,"50":311.4730361008796}},"hardware":{"gpu":"not recorded in profile JSON"},"provenance":{"artifact":"iclr_candidate_compatibility/remote_outputs/profiles/qwen3_8b_latency.json","artifact_sha256":"f46dc680f87cf0d0a9ea96fcd25c3ddf20a92afd84acda72f7ca72a918950a30","table":"batch_sizes","status":"retained_external","git_commit":null},"notes":["Measured workload-specific model-profile throughput. The often-used 106.98 docs/s value was not traceable to a retained measured artifact and is intentionally not included.","Do not use this profile as a universal H100 rate; supply your own measured throughput for economics projections."]}
@@ -0,0 +1,4 @@
1
+ c3d851bcfe66d9cc1aabc4ec8b61512110d8802ac0bcc7af3239a9ac6cec14a9 schema_version.json
2
+ 1471e7a937d87eeba58d19a801fb8c147f6982d2c1522b5da43f6986b92800d5 migrations.jsonl
3
+ fcbf2fa0456fc3fc2e30d1b945b05e7d7dcfa11b7187910ced8a3fa0e69f6720 research_summaries.json
4
+ 4ef2bdfc9f1803ef108400986be6a06915879d302e14a2757fc091c32b5b6e54 benchmark_profiles.jsonl