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
embedflow/cli.py ADDED
@@ -0,0 +1,1200 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import json
6
+ import platform
7
+ import random
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from . import __version__
15
+ from .compatibility.evaluate import evaluate_models, evaluate_with_native_rankings, load_qrels, load_queries
16
+ from .compatibility.report import report_markdown, write_report
17
+ from .config import (
18
+ CacheConfig,
19
+ DocumentsConfig,
20
+ EmbedFlowConfig,
21
+ IndexConfig,
22
+ MigrationConfig,
23
+ ModelConfig,
24
+ ProbeConfig,
25
+ TelemetryConfig,
26
+ hydrate_research_contract,
27
+ load_config,
28
+ save_config,
29
+ )
30
+ from .migration.compatibility import run_probe, save_probe
31
+ from .migration.state import DocumentStore
32
+ from .models import HashEmbeddingModel, load_embedding_model
33
+ from .registry import (
34
+ MATCH_EXACT,
35
+ load_benchmark_profiles,
36
+ load_evidence,
37
+ match_config,
38
+ match_evidence,
39
+ verify_registry,
40
+ )
41
+ from .runtime import build_faiss_from_documents, load_documents, open_engine
42
+
43
+
44
+ def _json(value: Any) -> None: print(json.dumps(value, indent=2, ensure_ascii=False, default=float))
45
+
46
+
47
+ def _normalize_device(value: str | None) -> str | None:
48
+ """Accept the common human spelling ``gpu`` while PyTorch uses ``cuda``."""
49
+ if value is None:
50
+ return None
51
+ value = str(value).strip()
52
+ return "cuda" if value.lower() == "gpu" else value
53
+
54
+
55
+ def _load_queries(path: str | Path) -> list[tuple[str, str]]:
56
+ path = Path(path)
57
+ if not path.exists(): raise FileNotFoundError(path)
58
+ rows: list[tuple[str, str]] = []
59
+ seen: set[str] = set()
60
+ with path.open() as handle:
61
+ for i, line in enumerate(handle, 1):
62
+ if not line.strip(): continue
63
+ try:
64
+ row = json.loads(line)
65
+ except json.JSONDecodeError as exc:
66
+ raise ValueError(f"invalid query JSON at {path}:{i}") from exc
67
+ if not isinstance(row, dict):
68
+ raise ValueError(f"query row {i} in {path} must be a JSON object")
69
+ qid = str(row.get("id", row.get("query_id", i))); text = row.get("text", row.get("query"))
70
+ if qid in seen:
71
+ raise ValueError(f"duplicate query ID {qid!r} in {path}")
72
+ if not isinstance(text, str) or not text.strip(): raise ValueError(f"query {qid} has no text")
73
+ seen.add(qid); rows.append((qid, text))
74
+ if not rows:
75
+ raise ValueError(f"query file is empty: {path}")
76
+ return rows
77
+
78
+
79
+ def _load_rankings(path: str | Path) -> dict[str, list[str]]:
80
+ """Load JSONL native rankings: {id/query_id, ranked_ids/ranking}."""
81
+ path = Path(path)
82
+ if not path.exists():
83
+ raise FileNotFoundError(path)
84
+ rows: dict[str, list[str]] = {}
85
+ with path.open() as handle:
86
+ for number, line in enumerate(handle, 1):
87
+ if not line.strip():
88
+ continue
89
+ try:
90
+ row = json.loads(line)
91
+ except json.JSONDecodeError as exc:
92
+ raise ValueError(f"invalid ranking JSON at {path}:{number}") from exc
93
+ if not isinstance(row, dict):
94
+ raise ValueError(f"ranking row {number} in {path} must be a JSON object")
95
+ query_id = str(row.get("id", row.get("query_id", number)))
96
+ if query_id in rows:
97
+ raise ValueError(f"duplicate ranking query ID {query_id!r} in {path}")
98
+ ranking = row.get("ranked_ids", row.get("ranking", row.get("results")))
99
+ if not isinstance(ranking, list):
100
+ raise ValueError(f"ranking {query_id!r} must contain a list")
101
+ rows[query_id] = [str(item.get("id")) if isinstance(item, dict) else str(item) for item in ranking]
102
+ if not rows:
103
+ raise ValueError(f"ranking file is empty: {path}")
104
+ return rows
105
+
106
+
107
+ def _set_model_local_paths(cfg: EmbedFlowConfig, model_root: str | Path | None) -> EmbedFlowConfig:
108
+ """Resolve known staged checkpoints without requiring a research checkout."""
109
+ registry = {
110
+ "sentence-transformers/all-MiniLM-L6-v2": "minilm_l6",
111
+ "Qwen/Qwen3-Embedding-0.6B": "qwen3_0_6b",
112
+ "Qwen/Qwen3-Embedding-4B": "qwen3_4b",
113
+ "Qwen/Qwen3-Embedding-8B": "qwen3_8b",
114
+ }
115
+ if model_root:
116
+ root = Path(model_root).expanduser().resolve()
117
+ for model in (cfg.source, cfg.target):
118
+ staged = root / registry.get(model.model, model.model)
119
+ if staged.exists():
120
+ model.local_path = str(staged)
121
+ return cfg
122
+
123
+
124
+ def _direct_analysis_config(args: argparse.Namespace) -> tuple[Path, EmbedFlowConfig]:
125
+ """Create a reusable config for the direct ``analyze`` UX."""
126
+ if not all((args.documents, args.index, args.source_model, args.target_model, args.probe_queries)):
127
+ raise ValueError("direct analyze requires --documents, --index, --source-model, --target-model, and --probe-queries")
128
+ output_dir = Path(args.output_dir or ".").expanduser().resolve()
129
+ output_dir.mkdir(parents=True, exist_ok=True)
130
+ config_path = output_dir / "embedflow.analysis.yaml"
131
+ source = hydrate_research_contract(ModelConfig(str(args.source_model)), project_root=Path(__file__).resolve().parents[1])
132
+ target = hydrate_research_contract(ModelConfig(str(args.target_model)), project_root=Path(__file__).resolve().parents[1])
133
+ index_value = str(args.index)
134
+ qdrant_url = index_value if args.backend == "qdrant" and "://" in index_value else None
135
+ index_path = index_value if qdrant_url else str(Path(index_value).expanduser().resolve())
136
+ cfg = EmbedFlowConfig(
137
+ source=source,
138
+ target=target,
139
+ index=IndexConfig(backend=args.backend, path=index_path, url=qdrant_url, collection=args.collection,
140
+ vector_name=args.vector_name, api_key_env=args.api_key_env, metric=args.metric,
141
+ ids=str(Path(args.index_ids).expanduser().resolve()) if args.index_ids else None),
142
+ documents=DocumentsConfig(path=str(Path(args.documents).expanduser().resolve())),
143
+ migration=MigrationConfig(candidate_depth="auto", kmax_probe=int(args.kmax or 500), probe_queries=int(args.limit or 100)),
144
+ cache=CacheConfig(path=str(output_dir / "embedflow_cache")),
145
+ probe=ProbeConfig(queries=str(Path(args.probe_queries).expanduser().resolve()), kmax=int(args.kmax or 500),
146
+ seed=int(args.seed if args.seed is not None else 42), limit=args.limit),
147
+ telemetry=TelemetryConfig(latency_log=str(output_dir / "logs" / "latency.jsonl")),
148
+ state_path=str(output_dir / "embedflow_state.json"),
149
+ dashboard_title=f"EmbedFlow — {source.model} → {target.model}",
150
+ )
151
+ _set_model_local_paths(cfg, args.model_root)
152
+ save_config(cfg, config_path)
153
+ return config_path, cfg
154
+
155
+
156
+ def _analysis_summary(result: dict[str, Any], *, config: EmbedFlowConfig, output: Path) -> None:
157
+ print("EmbedFlow Migration Analysis")
158
+ print("-" * 50)
159
+ print(f"Source model:\n {config.source.model}")
160
+ print(f"Target model:\n {config.target.model}")
161
+ print(f"Corpus:\n {DocumentStore(config.documents.path, config.documents.id_field, config.documents.text_field).size():,} documents")
162
+ print(f"Diagnostic:\n {str(result.get('diagnostic', 'UNKNOWN')).upper()}")
163
+ print(f"Recommended initial candidate depth:\n K = {result.get('recommended_k', 'n/a')}")
164
+ diagnostic = str(result.get("diagnostic", "UNKNOWN")).upper()
165
+ tail = {"SAFE": "stable", "EXPAND": "still changing", "UNSAFE_OR_UNCERTAIN": "uncertain"}.get(diagnostic, "not established")
166
+ print(f"Finite-tail behavior:\n {tail}")
167
+ print("ANN health:\n UNKNOWN (unless an exact/reference audit was supplied)")
168
+ print("Suggested strategy:\n PROGRESSIVE" if diagnostic == "SAFE" else "Suggested strategy:\n PROGRESSIVE_WITH_REVIEW")
169
+ print("Warning:\n SAFE is an empirical diagnostic, not a guarantee.")
170
+ print(f"\nSaved probe: {output}")
171
+
172
+
173
+ def _print_registry_match(match: Any, *, heading: str = "Known evidence", reused: bool = False) -> None:
174
+ """Render registry evidence without turning prior evidence into a verdict."""
175
+ if not match.records:
176
+ print(f"{heading}: none")
177
+ return
178
+ print("\n" + "-" * 50)
179
+ print(heading)
180
+ print("-" * 50)
181
+ print(f"Registry evidence match: {match.level}")
182
+ print(f"Exact source contract: {'yes' if match.exact_source_contract else 'no'}")
183
+ print(f"Exact target contract: {'yes' if match.exact_target_contract else 'no'}")
184
+ print(f"Exact corpus: {'yes' if match.exact_corpus else 'no'}")
185
+ print("Previously studied:")
186
+ for dataset in match.prior_datasets:
187
+ print(f" {dataset}")
188
+ print("Priority probe depths: " + ", ".join(str(k) for k in match.recommended_k) if match.recommended_k else "Priority probe depths: unavailable")
189
+ if match.level == MATCH_EXACT:
190
+ if reused:
191
+ print("REUSING canonical registry values for this exact match; the current probe still runs and is recorded separately.")
192
+ else:
193
+ print("Existing canonical benchmark available. Values may be reused only with --use-registry.")
194
+ else:
195
+ print("These results are prior evidence, not a compatibility decision for the current corpus.")
196
+ print("Recommended next action: run a finite-tail probe on this corpus.")
197
+ for row in match.records:
198
+ data = row.raw
199
+ dataset = data["dataset"]
200
+ size = dataset.get("corpus_size")
201
+ size_text = f"{int(size):,}" if size is not None else "size unavailable"
202
+ print(f"\n {dataset['name']} ({size_text} documents):")
203
+ if data.get("candidate_gap"):
204
+ display = ", ".join(f"G({k})={float(v):.5f}" for k, v in data["candidate_gap"].items())
205
+ print(f" {display}")
206
+ print(f" observed K*: {data.get('observed_migration_depth') if data.get('observed_migration_depth') is not None else 'unavailable'}")
207
+ print(f" CI-certified K*: {data.get('ci_certified_migration_depth') if data.get('ci_certified_migration_depth') is not None else 'unavailable'}")
208
+
209
+
210
+ def _registry_values(match: Any) -> list[dict[str, Any]]:
211
+ """Serialize canonical values that ``--use-registry`` actually reuses."""
212
+ values: list[dict[str, Any]] = []
213
+ for row in match.records:
214
+ data = row.to_dict()
215
+ values.append({
216
+ "evidence_id": row.evidence_id,
217
+ "dataset": data.get("dataset", {}).get("name"),
218
+ "candidate_gap": data.get("candidate_gap", {}),
219
+ "containment": data.get("containment", {}),
220
+ "source_quality": data.get("source_quality"),
221
+ "native_target_quality": data.get("native_target_quality"),
222
+ "restricted_target_quality": data.get("restricted_target_quality", {}),
223
+ "observed_migration_depth": data.get("observed_migration_depth"),
224
+ "ci_certified_migration_depth": data.get("ci_certified_migration_depth"),
225
+ "epsilon": data.get("epsilon"),
226
+ "provenance": data.get("provenance", {}),
227
+ })
228
+ return values
229
+
230
+
231
+ def cmd_registry_list(args: argparse.Namespace) -> int:
232
+ records = load_evidence()
233
+ if args.json:
234
+ _json([row.to_dict() for row in records])
235
+ return 0
236
+ print("SOURCE TARGET DATASET SIZE")
237
+ print("-" * 116)
238
+ for row in records:
239
+ dataset = row.dataset
240
+ size = dataset.get("corpus_size")
241
+ size_text = f"{int(size):,}" if size is not None else "-"
242
+ print(f"{row.source.get('canonical_model_id','')[:34]:34} {row.target.get('canonical_model_id','')[:29]:29} {str(dataset.get('name',''))[:40]:40} {size_text:>10}")
243
+ print(f"\n{len(records)} core evidence rows; use `embedflow registry show` for curves and provenance.")
244
+ return 0
245
+
246
+
247
+ def cmd_registry_show(args: argparse.Namespace) -> int:
248
+ match = match_evidence(source_model=args.source, target_model=args.target, records=load_evidence())
249
+ if not match.records:
250
+ print("No registry evidence found for this source-target transition.")
251
+ return 0
252
+ _print_registry_match(match, heading=f"Evidence for {args.source} -> {args.target}")
253
+ if args.json:
254
+ _json([row.to_dict() for row in match.records])
255
+ return 0
256
+
257
+
258
+ def cmd_registry_match(args: argparse.Namespace) -> int:
259
+ cfg = load_config(args.config)
260
+ corpus_fp = args.corpus_fingerprint
261
+ if not corpus_fp and args.corpus:
262
+ from .registry.loader import dataset_fingerprint
263
+ corpus_fp = dataset_fingerprint(args.corpus, id_field=cfg.documents.id_field, text_field=cfg.documents.text_field)
264
+ docs_size = None
265
+ if args.corpus:
266
+ docs_size = DocumentStore(args.corpus, cfg.documents.id_field, cfg.documents.text_field).size()
267
+ else:
268
+ try:
269
+ docs_size = DocumentStore(cfg.documents.path, cfg.documents.id_field, cfg.documents.text_field).size()
270
+ except (FileNotFoundError, ValueError):
271
+ pass
272
+ match = match_config(cfg, corpus_fingerprint=corpus_fp, corpus_name=args.corpus_name, corpus_size=docs_size)
273
+ if args.json:
274
+ _json(match.to_dict())
275
+ else:
276
+ _print_registry_match(match, heading="Registry evidence match")
277
+ return 0
278
+
279
+
280
+ def cmd_registry_verify(args: argparse.Namespace) -> int:
281
+ result = verify_registry(provenance_root=getattr(args, "research_root", None))
282
+ if args.json:
283
+ _json(result)
284
+ else:
285
+ print("REGISTRY VERIFICATION: " + ("PASS" if result["ok"] else "FAIL"))
286
+ print(f"Core evidence rows: {result['record_count']}")
287
+ print(f"Benchmark profiles: {result['profile_count']}")
288
+ print(f"Retained artifacts checked: {result.get('provenance_artifacts_checked', 0)}")
289
+ print(f"Semantic values checked: {result.get('semantic_values_checked', 0)}")
290
+ for message in result.get("warnings", []):
291
+ print(f"Warning: {message}")
292
+ for message in result.get("errors", []):
293
+ print(f"Error: {message}")
294
+ return 0 if result["ok"] else 2
295
+
296
+
297
+ def cmd_benchmark_profiles_list(args: argparse.Namespace) -> int:
298
+ profiles = load_benchmark_profiles()
299
+ if args.json:
300
+ _json([profile.to_dict() for profile in profiles])
301
+ return 0
302
+ print("PROFILE KIND STATUS")
303
+ print("-" * 106)
304
+ for profile in profiles:
305
+ print(f"{profile.profile_id[:50]:50} {str(profile.raw.get('kind',''))[:28]:28} {profile.raw.get('status','')}")
306
+ print("\nProfiles are measured workload-specific evidence, not universal latency or throughput claims.")
307
+ return 0
308
+
309
+
310
+ def _save_default_config(path: Path, args: argparse.Namespace) -> None:
311
+ source = hydrate_research_contract(ModelConfig(args.source_model or "embedflow/demo-source", dimension=args.dimension or 64), path.parent)
312
+ target = hydrate_research_contract(ModelConfig(args.target_model or "embedflow/demo-target", dimension=args.dimension or 64), path.parent)
313
+ cfg = EmbedFlowConfig(source=source, target=target,
314
+ index=IndexConfig(backend=args.backend, path=args.index or "./legacy.index"),
315
+ documents=DocumentsConfig(path=args.documents or "./documents.jsonl"),
316
+ cache=CacheConfig(path=args.cache or "./embedflow_cache"))
317
+ save_config(cfg, path); print(f"wrote {path}")
318
+
319
+
320
+ def cmd_init(args: argparse.Namespace) -> int:
321
+ path = Path(args.config)
322
+ if not path.exists():
323
+ if not args.source_model and not args.target_model and not args.documents and not args.index:
324
+ if sys.stdin.isatty():
325
+ print("EmbedFlow initialization")
326
+ args.backend = "qdrant" if input("Existing index backend [1] FAISS / [2] Qdrant (1): ").strip() == "2" else "faiss"
327
+ args.source_model = input("Source model: ").strip() or "embedflow/demo-source"
328
+ args.target_model = input("Target model: ").strip() or "embedflow/demo-target"
329
+ args.documents = input("Corpus JSONL path (./documents.jsonl): ").strip() or "./documents.jsonl"
330
+ args.index = input("Legacy index path (./legacy.index): ").strip() or "./legacy.index"
331
+ args.cache = input("Target cache path (./embedflow_cache): ").strip() or "./embedflow_cache"
332
+ _save_default_config(path, args); return 0
333
+ _save_default_config(path, args)
334
+ cfg = load_config(path)
335
+ docs = load_documents(cfg)
336
+ print(f"loaded {docs.size():,} documents from {cfg.documents.path}")
337
+ if cfg.index.backend == "faiss" and not Path(cfg.index.path).exists():
338
+ if not args.build_index: raise FileNotFoundError(f"legacy FAISS index missing: {cfg.index.path}; pass --build-index to create it")
339
+ model = load_embedding_model(cfg.source, model_root=path.parent / "models", device=args.device, demo=args.demo)
340
+ try: build_faiss_from_documents(cfg, model, docs); print(f"built legacy index at {cfg.index.path}")
341
+ finally: model.close()
342
+ # Ensure dimensions and index metadata are checked before the user serves.
343
+ engine = open_engine(path, device=args.device, demo=args.demo, start_worker=False)
344
+ try:
345
+ if args.queries:
346
+ print("Testing candidate compatibility…")
347
+ result = run_probe(engine.source_model, engine.target_model, engine.source_index, docs,
348
+ _load_queries(args.queries), kmax=args.kmax or cfg.migration.kmax_probe,
349
+ seed=42, limit=cfg.migration.probe_queries)
350
+ save_probe(result, Path(cfg.state_path).with_name("probe_result.json"))
351
+ print(f"Result: {result['diagnostic']}; Recommended candidate depth: K={result['recommended_k']}")
352
+ print("configuration validated; run `embedflow analyze` then `embedflow serve`")
353
+ _json(engine.plan.to_dict())
354
+ finally: engine.close()
355
+ return 0
356
+
357
+
358
+ def cmd_migrate(args: argparse.Namespace) -> int:
359
+ """One-command setup for an existing FAISS or Qdrant index."""
360
+ from .migration.facade import migrate
361
+
362
+ session = None
363
+ try:
364
+ session = migrate(
365
+ index=args.index,
366
+ old_model=args.old_model,
367
+ new_model=args.new_model,
368
+ documents=args.documents,
369
+ backend=args.backend,
370
+ index_url=args.index_url,
371
+ collection=args.collection,
372
+ vector_name=args.vector_name,
373
+ api_key_env=args.api_key_env,
374
+ metric=args.metric,
375
+ model_root=args.model_root,
376
+ device=_normalize_device(args.device),
377
+ cache_path=args.cache,
378
+ state_path=args.state,
379
+ config_path=args.config,
380
+ candidate_depth=args.candidate_depth,
381
+ kmax_probe=args.kmax_probe,
382
+ max_sync_misses=args.max_sync_misses,
383
+ background_batch_size=args.background_batch_size,
384
+ probe_queries=args.probe_queries,
385
+ probe_limit=args.probe_limit,
386
+ start_worker=not args.no_worker,
387
+ )
388
+ print("EmbedFlow migration ready")
389
+ print(f"source: {session.config.source.model}")
390
+ print(f"target: {session.config.target.model}")
391
+ print(f"index: {session.config.index.backend} ({session.config.index.path})")
392
+ print(f"candidate depth: K={session.plan.candidate_depth}")
393
+ print(f"diagnostic: {session.plan.diagnostic}")
394
+ if session.config_path:
395
+ print(f"configuration: {session.config_path}")
396
+ if args.no_serve:
397
+ _json(session.status())
398
+ return 0
399
+ print(f"serving at http://{args.host}:{args.port} (dashboard: /)")
400
+ session.serve(host=args.host, port=args.port, log_level=args.log_level)
401
+ return 0
402
+ finally:
403
+ if session is not None:
404
+ session.close()
405
+
406
+
407
+ def cmd_analyze(args: argparse.Namespace) -> int:
408
+ if args.config:
409
+ config_path = Path(args.config).expanduser().resolve()
410
+ cfg = load_config(config_path)
411
+ query_path = args.probe_queries or getattr(args, "queries", None) or cfg.probe.queries
412
+ if not query_path:
413
+ raise ValueError("analyze requires --probe-queries/--queries or probe.queries in the config")
414
+ else:
415
+ config_path, cfg = _direct_analysis_config(args)
416
+ query_path = args.probe_queries
417
+ docs = load_documents(cfg)
418
+ registry_match = match_config(
419
+ cfg,
420
+ corpus_fingerprint=getattr(args, "corpus_fingerprint", None),
421
+ corpus_name=getattr(args, "corpus_name", None),
422
+ corpus_size=docs.size(),
423
+ records=load_evidence(),
424
+ )
425
+ use_registry = bool(getattr(args, "use_registry", False))
426
+ _print_registry_match(registry_match, reused=use_registry)
427
+ if use_registry and registry_match.level != MATCH_EXACT:
428
+ raise ValueError("--use-registry requires an EXACT REGISTRY MATCH; prior/related evidence cannot be reused as a result")
429
+ engine = open_engine(config_path, device=_normalize_device(args.device), demo=args.demo, start_worker=False)
430
+ try:
431
+ queries = _load_queries(query_path)
432
+ result = run_probe(engine.source_model, engine.target_model, engine.source_index, docs, queries,
433
+ kmax=args.kmax or cfg.probe.kmax or cfg.migration.kmax_probe,
434
+ seed=args.seed if args.seed is not None else cfg.probe.seed,
435
+ limit=args.limit if args.limit is not None else cfg.probe.limit)
436
+ output_dir = Path(args.output_dir or Path(cfg.state_path).parent).expanduser().resolve()
437
+ output_dir.mkdir(parents=True, exist_ok=True)
438
+ out = Path(args.output or output_dir / "probe_result.json")
439
+ save_probe(result, out)
440
+ report = {
441
+ "schema_version": "0.1",
442
+ "source_model": cfg.source.model,
443
+ "target_model": cfg.target.model,
444
+ "corpus_documents": docs.size(),
445
+ "diagnostic": result["diagnostic"],
446
+ "recommended_initial_k": result.get("recommended_k"),
447
+ "observed_k_epsilon": None,
448
+ "epsilon": 0.01,
449
+ "ann_status": "UNKNOWN",
450
+ "recommendation": "Progressive migration is a reasonable candidate for further deployment validation." if str(result.get("diagnostic", "")).upper() == "SAFE" else "Further validation is required before relying on progressive migration.",
451
+ "native_target_index_used": False,
452
+ "finite_tail_behavior": {"SAFE": "stable", "EXPAND": "still changing", "UNSAFE_OR_UNCERTAIN": "uncertain"}.get(str(result.get("diagnostic", "UNKNOWN")).upper(), "not established"),
453
+ "t2_warning": result.get("warning", "T2-v1 is an empirical finite-tail diagnostic, not a compatibility guarantee."),
454
+ "probe": result,
455
+ "candidate_gap_curve": [],
456
+ "registry": registry_match.to_dict(),
457
+ "registry_reused": bool(use_registry and registry_match.level == MATCH_EXACT),
458
+ "registry_reuse": {
459
+ "used": bool(use_registry and registry_match.level == MATCH_EXACT),
460
+ "evidence_ids": [row.evidence_id for row in registry_match.records] if use_registry else [],
461
+ "reused_fields": ["candidate_gap", "containment", "native_target_quality", "source_quality", "observed_migration_depth", "ci_certified_migration_depth"] if use_registry else [],
462
+ "note": "Canonical values are prior measurements; the current-corpus probe remains the deployment analysis." if use_registry else "Canonical rows were displayed but not reused.",
463
+ },
464
+ "registry_reused_values": _registry_values(registry_match) if use_registry else [],
465
+ "registry_evidence": [row.to_dict() for row in registry_match.records],
466
+ "limitations": [
467
+ "No native target index or qrels were used; this is Mode B deployment analysis.",
468
+ "Recommended initial K is not observed K*.",
469
+ "ANN fidelity is UNKNOWN until an exact/reference source comparison is supplied.",
470
+ ],
471
+ }
472
+ write_report(output_dir / "migration_report.json", report)
473
+ (output_dir / "report.md").write_text(report_markdown(report))
474
+ _analysis_summary(result, config=cfg, output=out)
475
+ finally: engine.close()
476
+ return 0
477
+
478
+
479
+ def cmd_evaluate(args: argparse.Namespace) -> int:
480
+ """Run Mode A evaluation with qrels and native target evidence."""
481
+ cfg = load_config(args.config)
482
+ query_path = args.queries or cfg.probe.queries
483
+ if not query_path:
484
+ raise ValueError("evaluate requires --queries or probe.queries in the config")
485
+ if not args.qrels:
486
+ raise ValueError("evaluate requires --qrels; Mode A must have relevance labels")
487
+ docs = load_documents(cfg)
488
+ engine = open_engine(args.config, device=_normalize_device(args.device), demo=args.demo, start_worker=False)
489
+ evaluation_indexes: list[Any] = []
490
+ try:
491
+ queries = load_queries(query_path)
492
+ qrels = load_qrels(args.qrels)
493
+ native_rankings = _load_rankings(args.native_target_rankings) if args.native_target_rankings else None
494
+ native_index = None
495
+ reference_index = None
496
+ if args.native_target_index:
497
+ native_index = _load_evaluation_index(args.native_target_index, cfg.index.backend, cfg.index.metric, docs.documents,
498
+ engine.target_model.dimension, cfg.index.collection, cfg.index.url,
499
+ cfg.index.vector_name, cfg.index.api_key_env, cfg.index.nprobe)
500
+ evaluation_indexes.append(native_index)
501
+ if args.reference_index:
502
+ reference_index = _load_evaluation_index(args.reference_index, cfg.index.backend, cfg.index.metric, docs.documents,
503
+ engine.source_model.dimension, cfg.index.collection, cfg.index.url,
504
+ cfg.index.vector_name, cfg.index.api_key_env, cfg.index.nprobe)
505
+ evaluation_indexes.append(reference_index)
506
+ k_values = _parse_k_values(args.k_values or ",".join(map(str, cfg.probe.k_values)))
507
+ if native_rankings is not None:
508
+ result = evaluate_with_native_rankings(
509
+ source_model=engine.source_model, target_model=engine.target_model, source_index=engine.source_index,
510
+ documents=docs, queries=queries, native_target_rankings=native_rankings, qrels=qrels,
511
+ reference_source_index=reference_index, k_values=k_values, quality_k=args.quality_k,
512
+ epsilon=args.epsilon, bootstrap_resamples=args.bootstrap, seed=args.seed,
513
+ recommended_k=None,
514
+ )
515
+ else:
516
+ result = evaluate_models(
517
+ source_model=engine.source_model, target_model=engine.target_model, source_index=engine.source_index,
518
+ documents=docs, queries=queries, qrels=qrels, native_target_index=native_index,
519
+ reference_source_index=reference_index, k_values=k_values, quality_k=args.quality_k,
520
+ epsilon=args.epsilon, bootstrap_resamples=args.bootstrap, seed=args.seed,
521
+ )
522
+ output_dir = Path(args.output_dir).expanduser().resolve(); output_dir.mkdir(parents=True, exist_ok=True)
523
+ write_report(output_dir / "results.json", result)
524
+ (output_dir / "report.md").write_text(report_markdown(result))
525
+ curve_rows = result.get("candidate_gap_curve", [])
526
+ import csv
527
+ curve_path = output_dir / "candidate_gap_curve.csv"
528
+ with curve_path.open("w", newline="") as handle:
529
+ fieldnames = list(curve_rows[0]) if curve_rows else ["k", "candidate_gap", "containment"]
530
+ writer = csv.DictWriter(handle, fieldnames=fieldnames); writer.writeheader(); writer.writerows(curve_rows)
531
+ containment_path = output_dir / "containment_curve.csv"
532
+ containment_rows = [{key: row.get(key) for key in ("k", "containment", "queries")} for row in curve_rows]
533
+ with containment_path.open("w", newline="") as handle:
534
+ writer = csv.DictWriter(handle, fieldnames=["k", "containment", "queries"]); writer.writeheader(); writer.writerows(containment_rows)
535
+ summary: dict[str, Any] = {key: value for key, value in result.items()
536
+ if isinstance(value, (str, int, float, bool)) or value is None}
537
+ for group in ("source_quality", "native_target_quality"):
538
+ for key, value in (result.get(group) or {}).items():
539
+ if isinstance(value, (str, int, float, bool)) or value is None:
540
+ summary[f"{group}_{key}"] = value
541
+ with (output_dir / "results.csv").open("w", newline="") as handle:
542
+ fieldnames = list(summary) or ["status"]
543
+ writer = csv.DictWriter(handle, fieldnames=fieldnames); writer.writeheader(); writer.writerow(summary)
544
+ print(report_markdown(result))
545
+ print(f"\nWrote evaluation outputs to {output_dir}")
546
+ finally:
547
+ for evaluation_index in evaluation_indexes:
548
+ try:
549
+ evaluation_index.close()
550
+ except Exception:
551
+ pass
552
+ engine.close()
553
+ return 0
554
+
555
+
556
+ def _parse_k_values(value: str) -> list[int]:
557
+ try:
558
+ values = sorted({int(item.strip()) for item in str(value).split(",") if item.strip()})
559
+ except ValueError as exc:
560
+ raise ValueError("K values must be comma-separated integers") from exc
561
+ if not values or any(item < 1 for item in values):
562
+ raise ValueError("K values must contain positive integers")
563
+ return values
564
+
565
+
566
+ def _load_evaluation_index(path: str, backend: str, metric: str, documents: dict[str, str], dimension: int,
567
+ collection: str, url: str | None, vector_name: str | None = None,
568
+ api_key_env: str | None = "QDRANT_API_KEY", nprobe: int | None = None):
569
+ from .indexes import FaissIndex, NumpyIndex, QdrantIndex
570
+ if backend == "qdrant":
571
+ return QdrantIndex.connect(url or path, collection, dimension, documents=documents, metric=metric,
572
+ vector_name=vector_name, api_key_env=api_key_env)
573
+ try:
574
+ return FaissIndex.load(path, metric=metric, documents=documents, nprobe=nprobe)
575
+ except (RuntimeError, ValueError) as exc:
576
+ try:
577
+ return NumpyIndex.load(path, metric=metric, documents=documents)
578
+ except Exception as fallback_exc:
579
+ raise exc from fallback_exc
580
+
581
+
582
+ def cmd_doctor(args: argparse.Namespace) -> int:
583
+ """Check dependencies and a config without downloading or starting workers."""
584
+ checks: list[dict[str, Any]] = []
585
+ version = sys.version_info
586
+ checks.append({"name": "python", "ok": version >= (3, 10), "detail": platform.python_version()})
587
+ for module, label in (("numpy", "NumPy"), ("yaml", "PyYAML"), ("faiss", "FAISS"), ("torch", "PyTorch"), ("fastapi", "FastAPI"), ("qdrant_client", "qdrant-client")):
588
+ available = importlib.util.find_spec(module) is not None
589
+ checks.append({"name": label.lower().replace("-", "_"), "ok": available, "detail": "installed" if available else "not installed (optional where noted)"})
590
+ checks.append({"name": "cuda", "ok": True, "detail": _cuda_detail()})
591
+ config = None
592
+ if args.config:
593
+ try:
594
+ config = load_config(args.config)
595
+ checks.append({"name": "config", "ok": True, "detail": str(Path(args.config).resolve())})
596
+ checks.append({"name": "documents", "ok": Path(config.documents.path).exists(), "detail": config.documents.path})
597
+ qdrant_endpoint = config.index.url or config.index.path
598
+ index_exists = (Path(config.index.path).exists() if config.index.backend.lower() == "faiss"
599
+ else bool(config.index.url or "://" in str(qdrant_endpoint) or Path(config.index.path).exists()))
600
+ checks.append({"name": "index", "ok": index_exists, "detail": config.index.path})
601
+ normalization_ok = not (config.index.metric.lower() == "cosine" and
602
+ (config.source.normalization.lower() != "l2" or config.target.normalization.lower() != "l2"))
603
+ checks.append({"name": "normalization", "ok": normalization_ok,
604
+ "detail": f"metric={config.index.metric}; source={config.source.normalization}; target={config.target.normalization}"})
605
+ if Path(config.documents.path).exists():
606
+ docs = DocumentStore(config.documents.path, config.documents.id_field, config.documents.text_field)
607
+ checks.append({"name": "document_rows", "ok": docs.size() > 0, "detail": f"{docs.size():,} rows"})
608
+ if config.index.backend.lower() == "faiss" and Path(config.index.path).exists():
609
+ try:
610
+ from .indexes import FaissIndex, NumpyIndex
611
+ try:
612
+ index = FaissIndex.load(config.index.path, ids_path=config.index.ids, metric=config.index.metric,
613
+ documents=docs.documents if "docs" in locals() else None)
614
+ except (RuntimeError, ValueError) as exc:
615
+ try:
616
+ index = NumpyIndex.load(config.index.path, metric=config.index.metric,
617
+ documents=docs.documents if "docs" in locals() else None)
618
+ except Exception as fallback_exc:
619
+ raise exc from fallback_exc
620
+ dimension_ok = config.source.dimension is None or int(config.source.dimension) == int(index.dimension)
621
+ checks.append({"name": "index_dimension", "ok": dimension_ok,
622
+ "detail": f"index={index.dimension}; configured={config.source.dimension or index.dimension}"})
623
+ stored = index.metadata().get("model_fingerprint")
624
+ expected_fingerprint = config.source.fingerprint
625
+ if config.source.model.startswith("embedflow/demo"):
626
+ expected_fingerprint = HashEmbeddingModel(config.source.model, config.source.dimension or index.dimension).fingerprint
627
+ fingerprint_ok = not stored or stored == expected_fingerprint
628
+ checks.append({"name": "index_fingerprint", "ok": fingerprint_ok,
629
+ "detail": "matches source contract" if fingerprint_ok else "does not match source contract"})
630
+ except Exception as exc:
631
+ checks.append({"name": "index_integrity", "ok": False, "detail": str(exc)})
632
+ cache_path = Path(config.cache.path)
633
+ if cache_path.exists():
634
+ try:
635
+ from .cache import SQLiteVectorCache
636
+ cache = SQLiteVectorCache(cache_path, config.target.fingerprint, int(config.target.dimension or 0))
637
+ stats = cache.stats(); cache.close()
638
+ checks.append({"name": "cache_integrity", "ok": True,
639
+ "detail": f"{stats['cached_target_vectors']:,} vectors; dimension={stats['dimension']}"})
640
+ except Exception as exc:
641
+ checks.append({"name": "cache_integrity", "ok": False, "detail": str(exc)})
642
+ else:
643
+ checks.append({"name": "cache_integrity", "ok": True, "detail": "not initialized yet"})
644
+ checks.append({"name": "source_fingerprint", "ok": True, "detail": config.source.fingerprint[:16]})
645
+ checks.append({"name": "target_fingerprint", "ok": True, "detail": config.target.fingerprint[:16]})
646
+ except Exception as exc:
647
+ checks.append({"name": "config", "ok": False, "detail": str(exc)})
648
+ optional_checks = {"faiss", "qdrant_client", "fastapi", "torch", "pytorch"}
649
+ failed = [check for check in checks if not check["ok"] and check["name"] not in optional_checks]
650
+ if args.json:
651
+ _json({"checks": checks, "status": "FAIL" if failed else "PASS"})
652
+ else:
653
+ print("EmbedFlow doctor")
654
+ print("-" * 50)
655
+ for check in checks:
656
+ mark = "PASS" if check["ok"] else "WARN" if check["name"] in optional_checks else "FAIL"
657
+ print(f"{mark:5} {check['name']}: {check['detail']}")
658
+ print(f"\nOverall: {'FAIL' if failed else 'PASS'}")
659
+ return 1 if failed else 0
660
+
661
+
662
+ def _cuda_detail() -> str:
663
+ try:
664
+ import torch
665
+ if torch.cuda.is_available():
666
+ return f"available ({torch.cuda.get_device_name(0)})"
667
+ return "not available"
668
+ except Exception as exc:
669
+ return f"unavailable ({exc})"
670
+
671
+
672
+ def cmd_serve(args: argparse.Namespace) -> int:
673
+ device = _normalize_device(args.device)
674
+ engine = open_engine(args.config, device=device, demo=args.demo, start_worker=True)
675
+ from .serving.api import create_app
676
+ app = create_app(engine)
677
+ try:
678
+ import uvicorn
679
+ print(f"EmbedFlow serving at http://{args.host}:{args.port} (dashboard: /)")
680
+ uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level)
681
+ finally: engine.close()
682
+ return 0
683
+
684
+
685
+ def _with_engine(args: argparse.Namespace): return open_engine(args.config, device=_normalize_device(args.device), demo=args.demo, start_worker=True)
686
+
687
+
688
+ def cmd_status(args: argparse.Namespace) -> int:
689
+ engine = _with_engine(args)
690
+ try: _json(engine.status())
691
+ finally: engine.close()
692
+ return 0
693
+
694
+
695
+ def cmd_search(args: argparse.Namespace) -> int:
696
+ engine = _with_engine(args)
697
+ try: _json(engine.search(args.query, args.top_k))
698
+ finally: engine.close()
699
+ return 0
700
+
701
+
702
+ def cmd_prewarm(args: argparse.Namespace) -> int:
703
+ if args.documents is not None and (isinstance(args.documents, bool) or int(args.documents) != args.documents or int(args.documents) < 1):
704
+ raise ValueError("--documents must be a positive integer")
705
+ if not np.isfinite(float(args.fraction)) or not 0.0 < float(args.fraction) <= 1.0:
706
+ raise ValueError("--fraction must be finite and in (0, 1]")
707
+ engine = _with_engine(args)
708
+ try:
709
+ if args.ids:
710
+ ids = [x.strip() for x in args.ids.split(",") if x.strip()]
711
+ elif args.strategy == "explicit":
712
+ raise ValueError("--strategy explicit requires --ids")
713
+ elif args.strategy == "popular":
714
+ counts: dict[str, int] = {}
715
+ for row in engine.records:
716
+ for document_id in row.get("candidate_ids", []): counts[str(document_id)] = counts.get(str(document_id), 0) + 1
717
+ ids = [x for x, _ in sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))]
718
+ if args.documents: ids = ids[:int(args.documents)]
719
+ elif not ids: ids = list(engine.documents.documents)
720
+ elif args.documents:
721
+ ids = list(engine.documents.documents)[:int(args.documents)]
722
+ else:
723
+ rng = random.Random(args.seed); ids = list(engine.documents.documents); rng.shuffle(ids); ids = ids[:max(1, int(len(ids) * args.fraction))]
724
+ _json(engine.prewarm(ids, asynchronous=args.async_mode))
725
+ finally: engine.close()
726
+ return 0
727
+
728
+
729
+ def cmd_export_target(args: argparse.Namespace) -> int:
730
+ """Optional explicit full target backfill and index export."""
731
+ if isinstance(args.batch_size, bool) or int(args.batch_size) != args.batch_size or int(args.batch_size) < 1:
732
+ raise ValueError("--batch-size must be a positive integer")
733
+ engine = _with_engine(args)
734
+ try:
735
+ ids = list(engine.documents.documents)
736
+ missing = [x for x in ids if x not in engine.cache.contains(ids)]
737
+ print(f"materializing {len(missing):,} missing target vectors")
738
+ if missing:
739
+ docs = engine.documents.get(missing)
740
+ for start in range(0, len(missing), int(args.batch_size)):
741
+ chunk = missing[start:start + int(args.batch_size)]
742
+ engine.cache.put(chunk, engine.target_model.encode_documents([docs[x] for x in chunk], batch_size=args.batch_size))
743
+ from .indexes import FaissIndex, NumpyIndex, QdrantIndex
744
+ cached = engine.cache.get(ids); vectors = np.asarray([cached[x] for x in ids], dtype="float32")
745
+ if args.backend == "faiss":
746
+ try: out = FaissIndex.build(vectors, ids, path=args.output_index, metric=engine.cfg.index.metric, documents=engine.documents.documents,
747
+ metadata={"model_fingerprint": engine.target_model.fingerprint, "model_id": engine.target_model.model_id})
748
+ except (RuntimeError, ValueError) as exc:
749
+ if "FAISS" not in str(exc): raise
750
+ out = NumpyIndex.build(vectors, ids, path=args.output_index, metric=engine.cfg.index.metric, documents=engine.documents.documents,
751
+ metadata={"model_fingerprint": engine.target_model.fingerprint, "model_id": engine.target_model.model_id})
752
+ else:
753
+ out = QdrantIndex.build(args.output_index, args.collection, vectors, ids,
754
+ documents=engine.documents.documents, metric=engine.cfg.index.metric,
755
+ api_key_env=engine.cfg.index.api_key_env,
756
+ vector_name=engine.cfg.index.vector_name)
757
+ try:
758
+ print(f"exported target index: {out.metadata()}")
759
+ finally:
760
+ out.close()
761
+ finally: engine.close()
762
+ return 0
763
+
764
+
765
+ def economics_for(corpus_size: int, docs_per_second: float | None, gpu_price: float | None,
766
+ cached: int = 0) -> dict[str, Any]:
767
+ try:
768
+ corpus_int = int(corpus_size)
769
+ corpus_exact = float(corpus_size) == corpus_int
770
+ except (TypeError, ValueError, OverflowError):
771
+ corpus_int, corpus_exact = 0, False
772
+ if isinstance(corpus_size, bool) or not corpus_exact or corpus_int < 0:
773
+ raise ValueError("corpus_size must be a non-negative integer")
774
+ try:
775
+ cached_int = int(cached)
776
+ cached_exact = float(cached) == cached_int
777
+ except (TypeError, ValueError, OverflowError):
778
+ cached_int, cached_exact = 0, False
779
+ if isinstance(cached, bool) or not cached_exact or cached_int < 0:
780
+ raise ValueError("cached documents must be a non-negative integer")
781
+ if cached_int > corpus_int:
782
+ raise ValueError("cached documents cannot exceed corpus size")
783
+ price_value: float | None = None
784
+ if gpu_price is not None:
785
+ try:
786
+ price_value = float(gpu_price)
787
+ except (TypeError, ValueError, OverflowError) as exc:
788
+ raise ValueError("gpu price must be finite and non-negative") from exc
789
+ if not np.isfinite(price_value) or price_value < 0:
790
+ raise ValueError("gpu price must be finite and non-negative")
791
+ throughput: float | None = None
792
+ if docs_per_second is not None:
793
+ try:
794
+ throughput = float(docs_per_second)
795
+ except (TypeError, ValueError, OverflowError) as exc:
796
+ raise ValueError("docs_per_second must be finite and non-negative") from exc
797
+ if not np.isfinite(throughput) or throughput < 0:
798
+ raise ValueError("docs_per_second must be finite and non-negative")
799
+ if throughput is None or throughput <= 0:
800
+ return {"status": "NEEDS_THROUGHPUT", "corpus_documents": corpus_int, "cached_documents": cached_int,
801
+ "remaining_documents": max(0, corpus_int - cached_int),
802
+ "note": "Supply a measured or user-provided target_docs_per_second; projections are linear estimates."}
803
+ remaining = max(0, corpus_int - cached_int); hours = remaining / throughput / 3600.0
804
+ result = {"status": "ESTIMATE", "corpus_documents": corpus_int, "cached_documents": cached_int,
805
+ "remaining_documents": remaining, "target_docs_per_second": throughput,
806
+ "estimated_full_backfill_gpu_hours": corpus_int / throughput / 3600.0,
807
+ "estimated_remaining_gpu_hours": hours, "deferred_fraction": remaining / max(1, int(corpus_size)),
808
+ "note": "Linear projection from supplied throughput; not a hardware guarantee."}
809
+ if price_value is not None:
810
+ result["estimated_full_backfill_cost"] = result["estimated_full_backfill_gpu_hours"] * price_value
811
+ result["estimated_remaining_cost"] = hours * price_value
812
+ return result
813
+
814
+
815
+ def cmd_economics(args: argparse.Namespace) -> int:
816
+ if args.config:
817
+ cfg = load_config(args.config); docs = load_documents(cfg); cached = 0
818
+ try:
819
+ engine = open_engine(args.config, device="cpu", demo=args.demo, start_worker=False); cached = engine.cache.stats()["cached_target_vectors"]; engine.close()
820
+ except Exception: pass
821
+ corpus = docs.size()
822
+ dps = args.target_docs_per_sec if args.target_docs_per_sec is not None else cfg.economics.target_docs_per_second
823
+ price = args.gpu_price if args.gpu_price is not None else cfg.economics.gpu_price_per_hour
824
+ else: corpus, dps, price, cached = args.corpus_size, args.target_docs_per_sec, args.gpu_price, args.cached_documents
825
+ result = economics_for(corpus, dps, price, cached)
826
+ if args.json:
827
+ _json(result)
828
+ return 0
829
+ print("EmbedFlow Economics")
830
+ print("(Projection based on supplied measured throughput.)")
831
+ print("-" * 50)
832
+ if result.get("status") != "ESTIMATE":
833
+ print(result.get("note", "Supply target docs per second for an estimate."))
834
+ return 0
835
+ full_hours = float(result["estimated_full_backfill_gpu_hours"])
836
+ remaining_hours = float(result["estimated_remaining_gpu_hours"])
837
+ print("Full target backfill")
838
+ print(f" GPU-hours: {full_hours:,.2f}")
839
+ print(f" estimated cost: {_format_cost(result.get('estimated_full_backfill_cost'))}")
840
+ print(f" estimated one-GPU wall time: {full_hours:,.2f} hours")
841
+ print("\nCurrent target cache")
842
+ print(f" documents materialized: {int(result['cached_documents']):,}")
843
+ print(f" fraction materialized: {1.0 - float(result['deferred_fraction']):.2%}")
844
+ print(f" work remaining: {int(result['remaining_documents']):,} documents ({remaining_hours:,.2f} GPU-hours)")
845
+ print(f" upfront work deferred: {float(result['deferred_fraction']):.2%}")
846
+ if "estimated_remaining_cost" in result:
847
+ print(f" estimated remaining cost: {_format_cost(result['estimated_remaining_cost'])}")
848
+ return 0
849
+
850
+
851
+ def _format_cost(value: Any) -> str:
852
+ return "n/a" if value is None else f"${float(value):,.2f}"
853
+
854
+
855
+ def cmd_audit_index(args: argparse.Namespace) -> int:
856
+ cfg = load_config(args.config); engine = open_engine(args.config, device=_normalize_device(args.device), demo=args.demo, start_worker=False)
857
+ reference = None
858
+ try:
859
+ meta = engine.source_index.metadata(); result = {"candidate_compatibility": engine.plan.diagnostic, "ann_status": "UNKNOWN",
860
+ "index": meta, "checks": {"dimension_match": True, "metric": cfg.index.metric, "corpus_documents": engine.documents.size()},
861
+ "note": "ANN recall is UNKNOWN until an exact/reference index or saved reference candidates are supplied."}
862
+ if args.reference_index and args.queries:
863
+ from .indexes import FaissIndex, NumpyIndex
864
+ try: reference = FaissIndex.load(args.reference_index, metric=cfg.index.metric,
865
+ documents=engine.documents.documents, nprobe=cfg.index.nprobe)
866
+ except (RuntimeError, ValueError) as exc:
867
+ try:
868
+ reference = NumpyIndex.load(args.reference_index, metric=cfg.index.metric, documents=engine.documents.documents)
869
+ except Exception as fallback_exc:
870
+ raise exc from fallback_exc
871
+ refs = _load_queries(args.queries); vals = []
872
+ for _, text in refs[:max(1, int(args.limit or len(refs)))]:
873
+ vector = engine.source_model.encode_query(text); got = {x.document_id for x in engine.source_index.search(vector, args.k)}; exact = {x.document_id for x in reference.search(vector, args.k)}; vals.append(len(got & exact) / max(1, len(exact)))
874
+ recall = sum(vals) / max(1, len(vals)); result["ann_status"] = "PASS" if recall >= .95 else "WARNING"; result["ann_recall_at_k"] = recall; result["queries"] = len(vals); result["k"] = args.k; result["note"] = "Recall is overlap with the supplied reference index; it is not a T2-v1 compatibility measurement."
875
+ _json(result)
876
+ finally:
877
+ if reference is not None:
878
+ try:
879
+ reference.close()
880
+ except Exception:
881
+ pass
882
+ engine.close()
883
+ return 0
884
+
885
+
886
+ def _demo_dir(path: Path) -> Path:
887
+ path.mkdir(parents=True, exist_ok=True)
888
+ docs = path / "documents.jsonl"; queries = path / "probe_queries.jsonl"
889
+ if not docs.exists():
890
+ topics = [("aurora", "Auroras are caused when charged particles from the solar wind interact with gases in Earth's upper atmosphere."),
891
+ ("coffee", "Coffee beans are roasted seeds whose flavor depends on origin, roast temperature, and brewing method."),
892
+ ("batteries", "Lithium-ion batteries store energy through reversible movement of lithium ions between electrodes."),
893
+ ("volcano", "Volcanoes form when magma rises through weaknesses in Earth's crust and erupts at the surface."),
894
+ ("rainbow", "A rainbow appears when sunlight is refracted, reflected, and dispersed by water droplets."),
895
+ ("photosynthesis", "Plants use photosynthesis to convert light, water, and carbon dioxide into chemical energy."),
896
+ ("ocean", "Ocean currents transport heat around the planet and influence climate and marine ecosystems."),
897
+ ("sleep", "Sleep supports memory consolidation, immune function, and recovery from daily activity.")]
898
+ rows = []
899
+ for i in range(320):
900
+ topic, text = topics[i % len(topics)]; rows.append({"id": f"doc-{i:04d}", "text": f"{text} This is reference note {i} about {topic}."})
901
+ docs.write_text("\n".join(json.dumps(x) for x in rows) + "\n")
902
+ qrows = [{"id": f"q-{i}", "text": f"what explains {topic}?"} for i, (topic, _) in enumerate(topics)]
903
+ queries.write_text("\n".join(json.dumps(x) for x in qrows) + "\n")
904
+ cfg_path = path / "embedflow.yaml"
905
+ if not cfg_path.exists():
906
+ cfg = EmbedFlowConfig(source=ModelConfig("embedflow/demo-source", dimension=64), target=ModelConfig("embedflow/demo-target", dimension=64),
907
+ index=IndexConfig(backend="faiss", path=str(path / "legacy.index")), documents=DocumentsConfig(path=str(docs)),
908
+ cache=CacheConfig(path=str(path / "cache")), state_path=str(path / "state.json"), migration=MigrationConfig(candidate_depth=20, kmax_probe=50, probe_queries=8, max_sync_misses=2, background_batch_size=16))
909
+ save_config(cfg, cfg_path)
910
+ return cfg_path
911
+
912
+
913
+ # These are the exact frozen contracts used by the NQ research run. Keeping
914
+ # the IDs/revisions here means the real demo cannot silently switch to a
915
+ # different model revision or Qwen instruction template.
916
+ _REAL_DEMO_MODELS = {
917
+ "minilm_l6": {
918
+ "model": "sentence-transformers/all-MiniLM-L6-v2",
919
+ "revision": "1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
920
+ "dimension": 384,
921
+ },
922
+ "qwen3_0_6b": {
923
+ "model": "Qwen/Qwen3-Embedding-0.6B",
924
+ "revision": "97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3",
925
+ "dimension": 1024,
926
+ },
927
+ }
928
+
929
+
930
+ def _model_snapshot(repo: str, revision: str, destination: Path, download: bool) -> Path:
931
+ """Ensure a local, revision-pinned HF snapshot exists for the real demo."""
932
+ destination = destination.resolve()
933
+ weight_files = list(destination.rglob("*.safetensors")) + list(destination.rglob("*.bin")) + list(destination.rglob("*.pt"))
934
+ ready = (destination / "config.json").exists() and bool(weight_files)
935
+ if ready:
936
+ print(f"using cached {repo} snapshot: {destination}")
937
+ return destination
938
+ if not download:
939
+ raise FileNotFoundError(
940
+ f"model snapshot is missing or incomplete at {destination}. "
941
+ f"Re-run real-demo with --download, or place revision {revision} there."
942
+ )
943
+ try:
944
+ from huggingface_hub import snapshot_download
945
+ except ImportError as exc:
946
+ raise RuntimeError("real-demo downloads require huggingface_hub; install it in the active environment") from exc
947
+ destination.mkdir(parents=True, exist_ok=True)
948
+ print(f"downloading {repo}@{revision} to {destination} …")
949
+ try:
950
+ snapshot_download(repo_id=repo, revision=revision, local_dir=str(destination), max_workers=8)
951
+ except Exception as exc:
952
+ raise RuntimeError(
953
+ f"could not download {repo}@{revision}. Check network/Hugging Face access, "
954
+ f"then retry with --download: {exc}"
955
+ ) from exc
956
+ weight_files = list(destination.rglob("*.safetensors")) + list(destination.rglob("*.bin")) + list(destination.rglob("*.pt"))
957
+ if not (destination / "config.json").exists() or not weight_files:
958
+ raise RuntimeError(f"download completed but the snapshot at {destination} has no config/weights")
959
+ return destination
960
+
961
+
962
+ def _real_demo_config(path: Path, model_root: Path, download: bool) -> tuple[Path, EmbedFlowConfig]:
963
+ """Create a tiny-corpus config using the frozen MiniLM -> Qwen 0.6B pair."""
964
+ path.mkdir(parents=True, exist_ok=True)
965
+ # Reuse the same compact, inspectable corpus as the offline demo. Only the
966
+ # embedding models change; the query path and progressive cache are real.
967
+ _demo_dir(path)
968
+ source_info, target_info = _REAL_DEMO_MODELS["minilm_l6"], _REAL_DEMO_MODELS["qwen3_0_6b"]
969
+ source_dir = _model_snapshot(source_info["model"], source_info["revision"], model_root / "minilm_l6", download)
970
+ target_dir = _model_snapshot(target_info["model"], target_info["revision"], model_root / "qwen3_0_6b", download)
971
+ source = hydrate_research_contract(ModelConfig(**source_info), project_root=Path(__file__).resolve().parents[1])
972
+ target = hydrate_research_contract(ModelConfig(**target_info), project_root=Path(__file__).resolve().parents[1])
973
+ source.local_path, target.local_path = str(source_dir), str(target_dir)
974
+ cfg = EmbedFlowConfig(
975
+ source=source,
976
+ target=target,
977
+ index=IndexConfig(backend="faiss", path=str(path / "legacy.index"), metric="cosine", nprobe=64),
978
+ documents=DocumentsConfig(path=str(path / "documents.jsonl")),
979
+ migration=MigrationConfig(candidate_depth=20, kmax_probe=50, probe_queries=8,
980
+ max_sync_misses=2, background_batch_size=16),
981
+ cache=CacheConfig(path=str(path / "cache")),
982
+ state_path=str(path / "state.json"),
983
+ dashboard_title="EmbedFlow — MiniLM → Qwen3-0.6B",
984
+ )
985
+ cfg_path = path / "embedflow.yaml"
986
+ save_config(cfg, cfg_path)
987
+ with (path / "documents.jsonl").open() as handle:
988
+ doc_count = sum(1 for line in handle if line.strip())
989
+ (path / "corpus_provenance.json").write_text(json.dumps({
990
+ "kind": "synthetic_topics",
991
+ "documents": doc_count,
992
+ "note": "Small synthetic topic corpus for a deterministic, offline functional demo.",
993
+ }, indent=2))
994
+ return cfg_path, cfg
995
+
996
+
997
+ def cmd_real_demo(args: argparse.Namespace) -> int:
998
+ """Build/serve the small real-model MiniLM -> Qwen 0.6B demonstration."""
999
+ path = Path(args.path).resolve()
1000
+ device = _normalize_device(args.device)
1001
+ model_root = Path(args.model_root or (Path(__file__).resolve().parents[1] / "models")).resolve()
1002
+ if args.corpus != "topics":
1003
+ raise ValueError("the public real demo currently supports only the bundled topics corpus")
1004
+ cfg_path, cfg = _real_demo_config(path, model_root, args.download)
1005
+ docs = load_documents(cfg)
1006
+ index_path = Path(cfg.index.path)
1007
+ if not index_path.exists():
1008
+ print(f"building the MiniLM legacy index for {docs.size():,} demo documents …")
1009
+ source_model = load_embedding_model(cfg.source, model_root=model_root, device=device, demo=False)
1010
+ try:
1011
+ build_faiss_from_documents(cfg, source_model, docs)
1012
+ finally:
1013
+ source_model.close()
1014
+ print(f"built legacy index: {index_path}")
1015
+ else:
1016
+ print(f"reusing legacy index: {index_path}")
1017
+ engine = open_engine(cfg_path, device=device, demo=False, start_worker=False)
1018
+ try:
1019
+ print("running the frozen finite-pool compatibility probe …")
1020
+ probe_queries = _load_queries(path / "probe_queries.jsonl")
1021
+ result = run_probe(engine.source_model, engine.target_model, engine.source_index, docs,
1022
+ probe_queries, kmax=cfg.migration.kmax_probe,
1023
+ seed=42, limit=cfg.migration.probe_queries)
1024
+ save_probe(result, path / "probe_result.json")
1025
+ print(f"T2-v1 diagnostic: {result['diagnostic']}; recommended K={result['recommended_k']}")
1026
+ finally:
1027
+ engine.close()
1028
+ print(f"Real demo ready at {cfg_path}")
1029
+ if args.no_serve:
1030
+ return 0
1031
+ return cmd_serve(argparse.Namespace(config=str(cfg_path), device=device, demo=False,
1032
+ host=args.host, port=args.port, log_level=args.log_level))
1033
+
1034
+
1035
+ def cmd_demo(args: argparse.Namespace) -> int:
1036
+ path = Path(args.path).resolve(); cfg_path = _demo_dir(path); cfg = load_config(cfg_path); docs = load_documents(cfg)
1037
+ model = HashEmbeddingModel("embedflow/demo-source", 64)
1038
+ if args.backend == "faiss":
1039
+ build_faiss_from_documents(cfg, model, docs)
1040
+ else:
1041
+ from .indexes import QdrantIndex
1042
+ vectors = model.encode_documents(list(docs.documents.values()))
1043
+ qpath = path / "qdrant"
1044
+ qdrant_index = QdrantIndex.build(str(qpath), "embedflow-demo", vectors, list(docs.documents), documents=docs.documents)
1045
+ # The local Qdrant client holds an exclusive file lock. Close the
1046
+ # builder before opening a second client through ``open_engine`` in
1047
+ # this same process; remote clients are safe and the hook is
1048
+ # idempotent there as well.
1049
+ qdrant_index.close()
1050
+ cfg.index.backend = "qdrant"; cfg.index.path = str(qpath); cfg.index.collection = "embedflow-demo"; save_config(cfg, cfg_path)
1051
+ model.close()
1052
+ engine = open_engine(cfg_path, device="cpu", demo=True, start_worker=False)
1053
+ try:
1054
+ result = run_probe(engine.source_model, engine.target_model, engine.source_index, docs, _load_queries(path / "probe_queries.jsonl"), kmax=50, seed=42)
1055
+ save_probe(result, path / "probe_result.json"); print(f"T2-v1 diagnostic: {result['diagnostic']}; recommended K={result['recommended_k']}")
1056
+ finally: engine.close()
1057
+ print(f"Demo ready at {cfg_path}")
1058
+ if args.no_serve: return 0
1059
+ return cmd_serve(argparse.Namespace(config=str(cfg_path), device="cpu", demo=True, host=args.host, port=args.port, log_level="warning"))
1060
+
1061
+
1062
+ def build_parser() -> argparse.ArgumentParser:
1063
+ p = argparse.ArgumentParser(prog="embedflow", description="Progressive embedding-model migration")
1064
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
1065
+ sub = p.add_subparsers(dest="command", required=True)
1066
+ init = sub.add_parser("init", help="create or validate an EmbedFlow YAML configuration"); init.add_argument("--config", default="embedflow.yaml"); init.add_argument("--source-model"); init.add_argument("--target-model"); init.add_argument("--documents"); init.add_argument("--index"); init.add_argument("--cache"); init.add_argument("--queries", help="optional JSONL probe queries to run during initialization"); init.add_argument("--kmax", type=int); init.add_argument("--backend", choices=["faiss", "qdrant"], default="faiss"); init.add_argument("--dimension", type=int, default=64); init.add_argument("--build-index", action="store_true"); init.add_argument("--device", default="cpu"); init.add_argument("--demo", action="store_true"); init.set_defaults(func=cmd_init)
1067
+ migrate_cmd = sub.add_parser("migrate", help="connect an existing index and start progressive migration")
1068
+ migrate_cmd.add_argument("--index", required=True, help="FAISS index path, or Qdrant path/URL")
1069
+ migrate_cmd.add_argument("--documents", required=True, help="JSONL document store with id/text fields")
1070
+ migrate_cmd.add_argument("--old-model", required=True, help="source/legacy embedding model ID or local path")
1071
+ migrate_cmd.add_argument("--new-model", required=True, help="target embedding model ID or local path")
1072
+ migrate_cmd.add_argument("--backend", choices=["faiss", "qdrant"])
1073
+ migrate_cmd.add_argument("--index-url", help="optional Qdrant URL (otherwise --index is used)")
1074
+ migrate_cmd.add_argument("--collection", default="embedflow")
1075
+ migrate_cmd.add_argument("--vector-name", help="Qdrant named-vector key, when the collection uses named vectors")
1076
+ migrate_cmd.add_argument("--api-key-env", default="QDRANT_API_KEY", help="environment variable containing a Qdrant API key")
1077
+ migrate_cmd.add_argument("--metric", choices=["cosine", "dot", "inner_product"], default="cosine")
1078
+ migrate_cmd.add_argument("--model-root", help="directory containing staged research model snapshots")
1079
+ migrate_cmd.add_argument("--config", default="./embedflow.yaml", help="where to save the generated migration config")
1080
+ migrate_cmd.add_argument("--cache", default="./embedflow_cache")
1081
+ migrate_cmd.add_argument("--state", default="./embedflow_state.json")
1082
+ migrate_cmd.add_argument("--candidate-depth", type=int, default=50)
1083
+ migrate_cmd.add_argument("--kmax-probe", type=int, default=500)
1084
+ migrate_cmd.add_argument("--max-sync-misses", type=int, default=4)
1085
+ migrate_cmd.add_argument("--background-batch-size", type=int, default=32)
1086
+ migrate_cmd.add_argument("--probe-queries", help="optional JSONL queries; runs frozen T2-v1 before serving")
1087
+ migrate_cmd.add_argument("--probe-limit", type=int)
1088
+ migrate_cmd.add_argument("--device", default=None, help="override configured model device (cpu, cuda, or gpu)")
1089
+ migrate_cmd.add_argument("--no-worker", action="store_true", help="disable background materialization worker")
1090
+ migrate_cmd.add_argument("--no-serve", action="store_true", help="validate/write config without starting the API")
1091
+ migrate_cmd.add_argument("--host", default="127.0.0.1")
1092
+ migrate_cmd.add_argument("--port", type=int, default=8000)
1093
+ migrate_cmd.add_argument("--log-level", default="info")
1094
+ migrate_cmd.set_defaults(func=cmd_migrate)
1095
+ analyze = sub.add_parser("analyze", help="run the no-target-index finite-tail/T2-v1 diagnostic")
1096
+ analyze.add_argument("--config", help="existing EmbedFlow YAML config")
1097
+ analyze.add_argument("--documents", help="JSONL document store for direct analysis")
1098
+ analyze.add_argument("--index", help="existing FAISS/Numpy index for direct analysis")
1099
+ analyze.add_argument("--index-ids", help="optional FAISS ID sidecar path (defaults to <index>.ids.json)")
1100
+ analyze.add_argument("--backend", choices=["faiss", "qdrant"], default="faiss")
1101
+ analyze.add_argument("--metric", choices=["cosine", "dot", "inner_product"], default="cosine")
1102
+ analyze.add_argument("--collection", default="embedflow", help="Qdrant collection for direct analysis")
1103
+ analyze.add_argument("--vector-name", help="Qdrant named-vector key")
1104
+ analyze.add_argument("--api-key-env", default="QDRANT_API_KEY", help="Qdrant API-key environment variable")
1105
+ analyze.add_argument("--source-model", help="legacy/source model ID or local path")
1106
+ analyze.add_argument("--target-model", help="desired target model ID or local path")
1107
+ analyze.add_argument("--model-root", help="directory containing staged model snapshots")
1108
+ analyze.add_argument("--probe-queries", "--queries", dest="probe_queries", help="JSONL unlabeled probe queries")
1109
+ analyze.add_argument("--kmax", type=int)
1110
+ analyze.add_argument("--limit", type=int)
1111
+ analyze.add_argument("--seed", type=int, default=None)
1112
+ analyze.add_argument("--corpus-name", help="canonical registry dataset identifier, when known")
1113
+ analyze.add_argument("--corpus-fingerprint", help="precomputed dataset fingerprint for exact registry matching")
1114
+ analyze.add_argument("--use-registry", action="store_true", help="reuse canonical evidence only after an exact registry match")
1115
+ analyze.add_argument("--device", default=None, help="override configured model device (cpu, cuda, or gpu)")
1116
+ analyze.add_argument("--demo", action="store_true")
1117
+ analyze.add_argument("--output")
1118
+ analyze.add_argument("--output-dir")
1119
+ analyze.set_defaults(func=cmd_analyze)
1120
+ evaluate = sub.add_parser("evaluate", help="compute qrels/native-target candidate gaps (Mode A)")
1121
+ evaluate.add_argument("--config", required=True)
1122
+ evaluate.add_argument("--queries")
1123
+ evaluate.add_argument("--qrels", required=True)
1124
+ evaluate.add_argument("--native-target-index")
1125
+ evaluate.add_argument("--native-target-rankings", help="JSONL saved native target rankings")
1126
+ evaluate.add_argument("--reference-index", help="exact/reference source index for ANN fidelity")
1127
+ evaluate.add_argument("--k-values", default="10,20,50,100,200,500")
1128
+ evaluate.add_argument("--quality-k", type=int, default=10)
1129
+ evaluate.add_argument("--epsilon", type=float, default=0.01)
1130
+ evaluate.add_argument("--bootstrap", type=int, default=0, help="paired bootstrap resamples; 0 disables")
1131
+ evaluate.add_argument("--seed", type=int, default=42)
1132
+ evaluate.add_argument("--device", default=None, help="override configured model device (cpu, cuda, or gpu)")
1133
+ evaluate.add_argument("--demo", action="store_true")
1134
+ evaluate.add_argument("--output-dir", default="./results")
1135
+ evaluate.set_defaults(func=cmd_evaluate)
1136
+ serve = sub.add_parser("serve", help="start the FastAPI service and dashboard"); serve.add_argument("--config", default="embedflow.yaml"); serve.add_argument("--device", default=None, help="override configured model device (cpu, cuda, or gpu)"); serve.add_argument("--demo", action="store_true"); serve.add_argument("--host", default="127.0.0.1"); serve.add_argument("--port", type=int, default=8000); serve.add_argument("--log-level", default="info"); serve.set_defaults(func=cmd_serve)
1137
+ command_help = {"status": "show migration, cache, queue, and latency status", "search": "search with source retrieval and target reranking", "prewarm": "materialize selected target vectors", "audit-index": "compare ANN results with an exact/reference index"}
1138
+ for name, func in (("status", cmd_status), ("search", cmd_search), ("prewarm", cmd_prewarm), ("audit-index", cmd_audit_index)):
1139
+ sp = sub.add_parser(name, help=command_help[name]); sp.add_argument("--config", default="embedflow.yaml"); sp.add_argument("--device", default=None, help="override configured model device (cpu, cuda, or gpu)"); sp.add_argument("--demo", action="store_true"); sp.set_defaults(func=func)
1140
+ sub.choices["search"].add_argument("query"); sub.choices["search"].add_argument("--top-k", type=int, default=10)
1141
+ pre = sub.choices["prewarm"]; pre.add_argument("--documents", type=int); pre.add_argument("--fraction", type=float, default=.01); pre.add_argument("--ids"); pre.add_argument("--strategy", choices=["random", "popular", "explicit"], default="random"); pre.add_argument("--seed", type=int, default=42); pre.add_argument("--async", dest="async_mode", action="store_true")
1142
+ export = sub.add_parser("export-target", help="explicitly materialize all target vectors and build a target index"); export.add_argument("--config", default="embedflow.yaml"); export.add_argument("--output-index", required=True); export.add_argument("--backend", choices=["faiss", "qdrant"], default="faiss"); export.add_argument("--collection", default="embedflow-target"); export.add_argument("--batch-size", type=int, default=32); export.add_argument("--device", default="cpu"); export.add_argument("--demo", action="store_true"); export.set_defaults(func=cmd_export_target)
1143
+ audit = sub.choices["audit-index"]; audit.add_argument("--reference-index"); audit.add_argument("--queries"); audit.add_argument("--k", type=int, default=500); audit.add_argument("--limit", type=int)
1144
+ econ = sub.add_parser("economics", help="project backfill time and cost from supplied throughput"); econ.add_argument("--config"); econ.add_argument("--corpus-size", type=int, default=0); econ.add_argument("--cached-documents", type=int, default=0); econ.add_argument("--target-docs-per-sec", "--docs-per-second", dest="target_docs_per_sec", type=float); econ.add_argument("--gpu-price", type=float); econ.add_argument("--json", action="store_true", help="emit machine-readable JSON"); econ.add_argument("--demo", action="store_true"); econ.set_defaults(func=cmd_economics)
1145
+ demo = sub.add_parser("demo", help="run the self-contained offline progressive-migration demo"); demo.add_argument("--path", default="./examples/local_faiss_demo/runtime"); demo.add_argument("--backend", choices=["faiss", "qdrant"], default="faiss"); demo.add_argument("--no-serve", action="store_true"); demo.add_argument("--host", default="127.0.0.1"); demo.add_argument("--port", type=int, default=8000); demo.set_defaults(func=cmd_demo)
1146
+ real = sub.add_parser("real-demo", help="small real-model MiniLM -> Qwen3-0.6B demo")
1147
+ real.add_argument("--path", default="./examples/local_faiss_demo/nq_runtime")
1148
+ real.add_argument("--corpus", choices=["topics"], default="topics",
1149
+ help="bundled synthetic topic fixture (the public real demo corpus)")
1150
+ real.add_argument("--model-root", help="directory for revision-pinned model snapshots (default: ./models)")
1151
+ real.add_argument("--download", action="store_true", help="download missing public model snapshots from Hugging Face")
1152
+ real.add_argument("--device", default="cpu", help="cpu or cuda; use cuda when available")
1153
+ real.add_argument("--no-serve", action="store_true")
1154
+ real.add_argument("--host", default="127.0.0.1")
1155
+ real.add_argument("--port", type=int, default=8000)
1156
+ real.add_argument("--log-level", default="info")
1157
+ real.set_defaults(func=cmd_real_demo)
1158
+ doctor = sub.add_parser("doctor", help="check dependencies, paths, and configuration")
1159
+ doctor.add_argument("--config", default=None)
1160
+ doctor.add_argument("--json", action="store_true", help="emit machine-readable checks")
1161
+ doctor.set_defaults(func=cmd_doctor)
1162
+ registry = sub.add_parser("registry", help="inspect verified migration evidence shipped with EmbedFlow")
1163
+ registry_sub = registry.add_subparsers(dest="registry_command", required=True)
1164
+ registry_list = registry_sub.add_parser("list", help="list core migration evidence rows")
1165
+ registry_list.add_argument("--json", action="store_true")
1166
+ registry_list.set_defaults(func=cmd_registry_list)
1167
+ registry_show = registry_sub.add_parser("show", help="show curves and provenance for a model transition")
1168
+ registry_show.add_argument("--source", required=True)
1169
+ registry_show.add_argument("--target", required=True)
1170
+ registry_show.add_argument("--json", action="store_true")
1171
+ registry_show.set_defaults(func=cmd_registry_show)
1172
+ registry_match = registry_sub.add_parser("match", help="match a YAML config against registry contracts")
1173
+ registry_match.add_argument("--config", required=True)
1174
+ registry_match.add_argument("--corpus", help="optional JSONL corpus to fingerprint")
1175
+ registry_match.add_argument("--corpus-name", help="canonical registry dataset identifier")
1176
+ registry_match.add_argument("--corpus-fingerprint", help="precomputed dataset fingerprint")
1177
+ registry_match.add_argument("--json", action="store_true")
1178
+ registry_match.set_defaults(func=cmd_registry_match)
1179
+ registry_verify = registry_sub.add_parser("verify", help="validate packaged registry schemas, checksums, and provenance")
1180
+ registry_verify.add_argument("--research-root", help="optional retained research checkout root for byte-level provenance checks")
1181
+ registry_verify.add_argument("--json", action="store_true")
1182
+ registry_verify.set_defaults(func=cmd_registry_verify)
1183
+ benchmark_profiles = sub.add_parser("benchmark-profiles", help="list measured latency/throughput profiles")
1184
+ benchmark_profiles_sub = benchmark_profiles.add_subparsers(dest="benchmark_command", required=True)
1185
+ profiles_list = benchmark_profiles_sub.add_parser("list", help="list workload-specific measured profiles")
1186
+ profiles_list.add_argument("--json", action="store_true")
1187
+ profiles_list.set_defaults(func=cmd_benchmark_profiles_list)
1188
+ return p
1189
+
1190
+
1191
+ def main(argv: list[str] | None = None) -> int:
1192
+ args = build_parser().parse_args(argv)
1193
+ try: return int(args.func(args))
1194
+ except KeyboardInterrupt: print("interrupted", file=sys.stderr); return 130
1195
+ except (FileNotFoundError, RuntimeError, ValueError, TypeError, OSError, AttributeError) as exc:
1196
+ print(f"EmbedFlow error: {exc}", file=sys.stderr)
1197
+ return 2
1198
+
1199
+
1200
+ if __name__ == "__main__": raise SystemExit(main())