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/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """EmbedFlow: progressive embedding-model migration for existing indexes."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .config import EmbedFlowConfig, load_config
6
+
7
+
8
+ def migrate(*args, **kwargs):
9
+ """Start progressive migration over an existing FAISS/Qdrant index.
10
+
11
+ Imported lazily to keep the lightweight configuration package free of
12
+ model-serving dependencies at import time. See ``embedflow.migration``
13
+ for the ``MigrationSession`` type.
14
+ """
15
+ from .migration.facade import migrate as _migrate
16
+ return _migrate(*args, **kwargs)
17
+
18
+
19
+ def analyze_migration(*args, **kwargs):
20
+ """Run the leakage-safe no-target-index analysis programmatically."""
21
+ from .analysis import analyze_migration as _analyze_migration
22
+ return _analyze_migration(*args, **kwargs)
23
+
24
+
25
+ __all__ = ["EmbedFlowConfig", "load_config", "migrate", "analyze_migration", "__version__"]
embedflow/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
embedflow/analysis.py ADDED
@@ -0,0 +1,192 @@
1
+ """Programmatic Mode-B migration analysis.
2
+
3
+ The CLI is the most discoverable interface, but applications and notebooks
4
+ often already hold a parsed EmbedFlowConfig. This module delegates to the
5
+ same runtime and frozen T2-v1 implementation used by ``embedflow analyze``;
6
+ it does not introduce a second scoring path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import tempfile
13
+ from collections.abc import Iterable, Mapping
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .compatibility.report import report_markdown, write_report
18
+ from .config import EmbedFlowConfig, from_dict, load_config, save_config
19
+ from .migration.compatibility import run_probe, save_probe
20
+ from .registry import load_evidence, match_config
21
+ from .runtime import load_documents, open_engine
22
+
23
+
24
+ def _query_rows(value: str | Path | Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
25
+ if isinstance(value, (str, Path)):
26
+ path = Path(value)
27
+ if not path.exists():
28
+ raise FileNotFoundError(path)
29
+ rows: list[tuple[str, str]] = []
30
+ seen: set[str] = set()
31
+ with path.open(encoding="utf-8") as handle:
32
+ for number, line in enumerate(handle, 1):
33
+ if not line.strip():
34
+ continue
35
+ try:
36
+ item = json.loads(line)
37
+ except json.JSONDecodeError as exc:
38
+ raise ValueError(f"invalid query JSON at {path}:{number}") from exc
39
+ if not isinstance(item, Mapping):
40
+ raise ValueError(f"query row {number} in {path} must be a JSON object")
41
+ query_id = str(item.get("id", item.get("query_id", number)))
42
+ text = item.get("text", item.get("query"))
43
+ if query_id in seen:
44
+ raise ValueError(f"duplicate query ID {query_id!r} in {path}")
45
+ if not isinstance(text, str) or not text.strip():
46
+ raise ValueError(f"query {query_id!r} has no text")
47
+ seen.add(query_id); rows.append((query_id, text))
48
+ if not rows:
49
+ raise ValueError(f"query file is empty: {value}")
50
+ return rows
51
+ try:
52
+ rows = [(str(query_id), text) for query_id, text in value]
53
+ except (TypeError, ValueError) as exc:
54
+ raise ValueError("probe_queries must be an iterable of (query_id, text) pairs") from exc
55
+ if len({query_id for query_id, _ in rows}) != len(rows):
56
+ raise ValueError("probe_queries must not contain duplicate query IDs")
57
+ if not rows or any(not isinstance(text, str) or not text.strip() for _, text in rows):
58
+ raise ValueError("probe_queries must contain non-empty query text")
59
+ return rows
60
+
61
+
62
+ def _registry_values(match: Any) -> list[dict[str, Any]]:
63
+ """Return only the canonical measurements explicitly reused by a match."""
64
+ values: list[dict[str, Any]] = []
65
+ for row in match.records:
66
+ data = row.to_dict()
67
+ values.append({
68
+ "evidence_id": row.evidence_id,
69
+ "dataset": data.get("dataset", {}).get("name"),
70
+ "candidate_gap": data.get("candidate_gap", {}),
71
+ "containment": data.get("containment", {}),
72
+ "source_quality": data.get("source_quality"),
73
+ "native_target_quality": data.get("native_target_quality"),
74
+ "restricted_target_quality": data.get("restricted_target_quality", {}),
75
+ "observed_migration_depth": data.get("observed_migration_depth"),
76
+ "ci_certified_migration_depth": data.get("ci_certified_migration_depth"),
77
+ "epsilon": data.get("epsilon"),
78
+ "provenance": data.get("provenance", {}),
79
+ })
80
+ return values
81
+
82
+
83
+ def analyze_migration(
84
+ config: str | Path | EmbedFlowConfig | Mapping[str, Any],
85
+ *,
86
+ probe_queries: str | Path | Iterable[tuple[str, str]] | None = None,
87
+ device: str | None = None,
88
+ demo: bool = False,
89
+ corpus_name: str | None = None,
90
+ corpus_fingerprint: str | None = None,
91
+ use_registry: bool = False,
92
+ output_dir: str | Path | None = None,
93
+ ) -> dict[str, Any]:
94
+ """Run leakage-safe no-target-index analysis and return a JSON report.
95
+
96
+ ``use_registry`` only succeeds for an exact contract-and-corpus match. A
97
+ prior or related row is displayed in the returned ``registry`` field but
98
+ never converted into a current-corpus compatibility decision.
99
+ """
100
+ temporary_config: Path | None = None
101
+ if isinstance(config, (str, Path)):
102
+ config_path = Path(config).expanduser().resolve()
103
+ cfg = load_config(config_path)
104
+ else:
105
+ cfg = config if isinstance(config, EmbedFlowConfig) else from_dict(dict(config))
106
+ cfg.validate()
107
+ # open_engine intentionally has one configuration-loading path.
108
+ # Serialize an in-memory config to a short-lived file so this API
109
+ # inherits all of its validation, model, and index checks.
110
+ cfg.resolve_paths(Path.cwd().resolve())
111
+ handle = tempfile.NamedTemporaryFile(prefix="embedflow-analysis-", suffix=".yaml", delete=False)
112
+ handle.close()
113
+ temporary_config = Path(handle.name)
114
+ save_config(cfg, temporary_config)
115
+ config_path = temporary_config
116
+ if probe_queries is None:
117
+ probe_queries = cfg.probe.queries
118
+ if probe_queries is None:
119
+ raise ValueError("probe_queries or config.probe.queries is required")
120
+ engine = None
121
+ try:
122
+ docs = load_documents(cfg)
123
+ registry = match_config(
124
+ cfg,
125
+ corpus_fingerprint=corpus_fingerprint,
126
+ corpus_name=corpus_name,
127
+ corpus_size=docs.size(),
128
+ records=load_evidence(),
129
+ )
130
+ if use_registry and registry.level != "EXACT REGISTRY MATCH":
131
+ raise ValueError("--use-registry requires an EXACT REGISTRY MATCH")
132
+ engine = open_engine(config_path, device=device, demo=demo, start_worker=False)
133
+ result = run_probe(
134
+ engine.source_model,
135
+ engine.target_model,
136
+ engine.source_index,
137
+ docs,
138
+ _query_rows(probe_queries),
139
+ kmax=cfg.probe.kmax,
140
+ seed=cfg.probe.seed,
141
+ limit=cfg.probe.limit,
142
+ )
143
+ report: dict[str, Any] = {
144
+ "schema_version": "0.1",
145
+ "source_model": cfg.source.model,
146
+ "target_model": cfg.target.model,
147
+ "corpus_documents": docs.size(),
148
+ "diagnostic": result["diagnostic"],
149
+ "recommended_initial_k": result.get("recommended_k"),
150
+ "observed_k_epsilon": None,
151
+ "epsilon": cfg.probe.epsilon,
152
+ "ann_status": "UNKNOWN",
153
+ "native_target_index_used": False,
154
+ "registry": registry.to_dict(),
155
+ "registry_reused": bool(use_registry),
156
+ "registry_reuse": {
157
+ "used": bool(use_registry),
158
+ "evidence_ids": [row.evidence_id for row in registry.records] if use_registry else [],
159
+ "reused_fields": ["candidate_gap", "containment", "native_target_quality", "source_quality", "observed_migration_depth", "ci_certified_migration_depth"] if use_registry else [],
160
+ "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.",
161
+ },
162
+ "registry_reused_values": _registry_values(registry) if use_registry else [],
163
+ "registry_evidence": [row.to_dict() for row in registry.records],
164
+ "probe": result,
165
+ "candidate_gap_curve": [],
166
+ "t2_warning": result.get("warning"),
167
+ "recommendation": (
168
+ "Progressive migration is a reasonable candidate for further deployment validation."
169
+ if str(result.get("diagnostic", "")).upper() == "SAFE"
170
+ else "Further validation is required before relying on progressive migration."
171
+ ),
172
+ "limitations": [
173
+ "No native target index or qrels were used; this is Mode B deployment analysis.",
174
+ "Recommended initial K is not observed K*.",
175
+ "ANN fidelity is UNKNOWN until an exact/reference source comparison is supplied.",
176
+ ],
177
+ }
178
+ if output_dir is not None:
179
+ destination = Path(output_dir).expanduser().resolve()
180
+ destination.mkdir(parents=True, exist_ok=True)
181
+ save_probe(result, destination / "probe_result.json")
182
+ write_report(destination / "migration_report.json", report)
183
+ (destination / "report.md").write_text(report_markdown(report), encoding="utf-8")
184
+ return report
185
+ finally:
186
+ if engine is not None:
187
+ engine.close()
188
+ if temporary_config is not None:
189
+ temporary_config.unlink(missing_ok=True)
190
+
191
+
192
+ __all__ = ["analyze_migration"]
@@ -0,0 +1,4 @@
1
+ from .base import TargetVectorCache
2
+ from .persistent_cache import CacheCorruptionError, SQLiteVectorCache
3
+
4
+ __all__ = ["TargetVectorCache", "SQLiteVectorCache", "CacheCorruptionError"]
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ class TargetVectorCache(ABC):
10
+ @abstractmethod
11
+ def get(self, document_ids: list[str]) -> dict[str, np.ndarray]:
12
+ raise NotImplementedError
13
+
14
+ @abstractmethod
15
+ def put(self, document_ids: list[str], vectors: np.ndarray) -> None:
16
+ raise NotImplementedError
17
+
18
+ @abstractmethod
19
+ def contains(self, document_ids: list[str]) -> set[str]:
20
+ raise NotImplementedError
21
+
22
+ @abstractmethod
23
+ def stats(self) -> dict[str, Any]:
24
+ raise NotImplementedError
25
+
26
+ @abstractmethod
27
+ def close(self) -> None:
28
+ raise NotImplementedError
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import sqlite3
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from .base import TargetVectorCache
13
+
14
+
15
+ class CacheCorruptionError(RuntimeError):
16
+ pass
17
+
18
+
19
+ class SQLiteVectorCache(TargetVectorCache):
20
+ """Persistent target-vector cache keyed by document and model fingerprint."""
21
+
22
+ # SQLite builds differ in their maximum number of bound variables. Keep
23
+ # lookups below the conservative default so prewarming large corpora works
24
+ # on older and embedded SQLite versions as well as on modern builds.
25
+ _LOOKUP_BATCH_SIZE = 900
26
+
27
+ def __init__(self, path: str | Path, model_fingerprint: str, dimension: int,
28
+ dtype: str = "float32"):
29
+ p = Path(path)
30
+ if p.suffix in {".sqlite", ".sqlite3", ".db"}:
31
+ self.db_path = p
32
+ self.root = p.parent
33
+ else:
34
+ self.root = p
35
+ self.db_path = p / "cache.sqlite3"
36
+ self.root.mkdir(parents=True, exist_ok=True)
37
+ self.model_fingerprint = str(model_fingerprint).strip()
38
+ if not self.model_fingerprint:
39
+ raise ValueError("model_fingerprint must be non-empty")
40
+ try:
41
+ parsed_dimension = int(dimension)
42
+ exact = float(dimension) == parsed_dimension
43
+ except (TypeError, ValueError, OverflowError):
44
+ parsed_dimension, exact = 0, False
45
+ if isinstance(dimension, bool) or not exact or parsed_dimension < 1:
46
+ raise ValueError("cache dimension must be a positive integer")
47
+ self.dimension = parsed_dimension
48
+ try:
49
+ self.dtype = np.dtype(dtype)
50
+ except TypeError as exc:
51
+ raise ValueError(f"unsupported cache dtype: {dtype!r}") from exc
52
+ if self.dtype.kind not in {"f", "i", "u"}:
53
+ raise ValueError("cache dtype must be a numeric vector dtype")
54
+ self._lock = threading.RLock()
55
+ self._db = None
56
+ try:
57
+ self._db = sqlite3.connect(str(self.db_path), check_same_thread=False, timeout=30)
58
+ self._db.execute("PRAGMA busy_timeout=30000")
59
+ self._db.execute("PRAGMA journal_mode=WAL")
60
+ self._db.execute("PRAGMA synchronous=NORMAL")
61
+ self._db.execute("""CREATE TABLE IF NOT EXISTS target_vectors (
62
+ document_id TEXT NOT NULL,
63
+ model_fingerprint TEXT NOT NULL,
64
+ dimension INTEGER NOT NULL,
65
+ dtype TEXT NOT NULL,
66
+ vector BLOB NOT NULL,
67
+ checksum TEXT NOT NULL,
68
+ created_at REAL NOT NULL,
69
+ accessed_at REAL NOT NULL,
70
+ PRIMARY KEY(document_id, model_fingerprint)
71
+ )""")
72
+ self._db.execute("CREATE INDEX IF NOT EXISTS idx_target_vectors_model ON target_vectors(model_fingerprint)")
73
+ self._db.execute("""CREATE TABLE IF NOT EXISTS cache_counters (
74
+ model_fingerprint TEXT PRIMARY KEY,
75
+ hits INTEGER NOT NULL DEFAULT 0,
76
+ misses INTEGER NOT NULL DEFAULT 0
77
+ )""")
78
+ self._db.execute("""CREATE TABLE IF NOT EXISTS cache_access_events (
79
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ model_fingerprint TEXT NOT NULL,
81
+ hit INTEGER NOT NULL,
82
+ accessed_at REAL NOT NULL
83
+ )""")
84
+ self._db.execute("CREATE INDEX IF NOT EXISTS idx_cache_events_model ON cache_access_events(model_fingerprint, event_id)")
85
+ self._db.execute("INSERT OR IGNORE INTO cache_counters(model_fingerprint) VALUES (?)", (self.model_fingerprint,))
86
+ self._db.commit()
87
+ except sqlite3.DatabaseError as exc:
88
+ if self._db is not None:
89
+ self._db.close()
90
+ self._db = None
91
+ raise CacheCorruptionError(f"invalid SQLite target-vector cache: {self.db_path}") from exc
92
+ self._closed = False
93
+
94
+ def _ensure_open(self) -> None:
95
+ if self._closed or self._db is None:
96
+ raise RuntimeError("target vector cache is closed")
97
+
98
+ def _decode(self, row: tuple[Any, ...]) -> np.ndarray:
99
+ document_id, _, dimension, dtype, blob, checksum, *_ = row
100
+ if int(dimension) != self.dimension or np.dtype(dtype) != self.dtype:
101
+ raise CacheCorruptionError(f"cache contract mismatch for document {document_id}")
102
+ if hashlib.sha256(blob).hexdigest() != checksum:
103
+ raise CacheCorruptionError(f"cache checksum mismatch for document {document_id}")
104
+ value = np.frombuffer(blob, dtype=self.dtype).copy()
105
+ if value.size != self.dimension or not np.isfinite(value).all():
106
+ raise CacheCorruptionError(f"invalid cached vector for document {document_id}")
107
+ return value.astype("float32", copy=False)
108
+
109
+ def get(self, document_ids: list[str]) -> dict[str, np.ndarray]:
110
+ ids = [str(x) for x in document_ids]
111
+ if not ids: return {}
112
+ with self._lock:
113
+ self._ensure_open()
114
+ rows: list[tuple[Any, ...]] = []
115
+ for start in range(0, len(ids), self._LOOKUP_BATCH_SIZE):
116
+ chunk = ids[start:start + self._LOOKUP_BATCH_SIZE]
117
+ placeholders = ",".join("?" for _ in chunk)
118
+ rows.extend(self._db.execute(
119
+ f"SELECT document_id,model_fingerprint,dimension,dtype,vector,checksum,created_at,accessed_at "
120
+ f"FROM target_vectors WHERE model_fingerprint=? AND document_id IN ({placeholders})",
121
+ [self.model_fingerprint, *chunk]).fetchall())
122
+ now = time.time()
123
+ values = {str(row[0]): self._decode(row) for row in rows}
124
+ if rows:
125
+ self._db.executemany("UPDATE target_vectors SET accessed_at=? WHERE document_id=? AND model_fingerprint=?",
126
+ [(now, str(row[0]), self.model_fingerprint) for row in rows])
127
+ # Count accesses, rather than unique rows, so the hit-rate remains
128
+ # meaningful when a caller supplies duplicate IDs.
129
+ hits = sum(document_id in values for document_id in ids)
130
+ misses = len(ids) - hits
131
+ self._db.execute("UPDATE cache_counters SET hits=hits+?,misses=misses+? WHERE model_fingerprint=?",
132
+ (hits, misses, self.model_fingerprint))
133
+ self._db.executemany("INSERT INTO cache_access_events(model_fingerprint,hit,accessed_at) VALUES (?,?,?)",
134
+ [(self.model_fingerprint, int(document_id in values), now) for document_id in ids])
135
+ # Keep the persistent telemetry table bounded while retaining a
136
+ # useful recent-hit-rate window for status and dashboards.
137
+ self._db.execute("""DELETE FROM cache_access_events
138
+ WHERE model_fingerprint=? AND event_id NOT IN
139
+ (SELECT event_id FROM cache_access_events WHERE model_fingerprint=? ORDER BY event_id DESC LIMIT 1000)""",
140
+ (self.model_fingerprint, self.model_fingerprint))
141
+ self._db.commit()
142
+ return values
143
+
144
+ def put(self, document_ids: list[str], vectors: np.ndarray) -> None:
145
+ ids = [str(x) for x in document_ids]
146
+ values = np.asarray(vectors, dtype=self.dtype)
147
+ if values.ndim != 2 or values.shape != (len(ids), self.dimension):
148
+ raise ValueError(f"vectors must have shape ({len(ids)}, {self.dimension})")
149
+ if len(set(ids)) != len(ids):
150
+ raise ValueError("duplicate IDs in cache write")
151
+ if not np.isfinite(values).all():
152
+ raise ValueError("cannot cache non-finite vectors")
153
+ now = time.time(); records = []
154
+ for document_id, value in zip(ids, values):
155
+ blob = np.ascontiguousarray(value).tobytes()
156
+ records.append((document_id, self.model_fingerprint, self.dimension, self.dtype.name, sqlite3.Binary(blob),
157
+ hashlib.sha256(blob).hexdigest(), now, now))
158
+ with self._lock:
159
+ self._ensure_open()
160
+ self._db.executemany("""INSERT INTO target_vectors
161
+ (document_id,model_fingerprint,dimension,dtype,vector,checksum,created_at,accessed_at)
162
+ VALUES (?,?,?,?,?,?,?,?)
163
+ ON CONFLICT(document_id,model_fingerprint) DO UPDATE SET
164
+ dimension=excluded.dimension,dtype=excluded.dtype,vector=excluded.vector,
165
+ checksum=excluded.checksum,accessed_at=excluded.accessed_at""", records)
166
+ self._db.commit()
167
+
168
+ def contains(self, document_ids: list[str]) -> set[str]:
169
+ return set(self.get(document_ids))
170
+
171
+ def stats(self) -> dict[str, Any]:
172
+ with self._lock:
173
+ self._ensure_open()
174
+ count = self._db.execute("SELECT COUNT(*) FROM target_vectors WHERE model_fingerprint=?", (self.model_fingerprint,)).fetchone()[0]
175
+ total = self._db.execute("SELECT COUNT(*) FROM target_vectors").fetchone()[0]
176
+ counters = self._db.execute("SELECT hits,misses FROM cache_counters WHERE model_fingerprint=?",
177
+ (self.model_fingerprint,)).fetchone() or (0, 0)
178
+ recent = self._db.execute("""SELECT SUM(hit),COUNT(*) FROM cache_access_events
179
+ WHERE model_fingerprint=? AND event_id IN
180
+ (SELECT event_id FROM cache_access_events WHERE model_fingerprint=? ORDER BY event_id DESC LIMIT 100)""",
181
+ (self.model_fingerprint, self.model_fingerprint)).fetchone()
182
+ total_hits, total_misses = int(counters[0]), int(counters[1])
183
+ recent_hits, recent_count = int(recent[0] or 0), int(recent[1] or 0)
184
+ return {"cached_target_vectors": int(count), "all_model_vectors": int(total),
185
+ "model_fingerprint": self.model_fingerprint, "dimension": self.dimension,
186
+ "cache_path": str(self.db_path), "total_cache_hits": total_hits,
187
+ "total_cache_misses": total_misses,
188
+ "recent_hit_rate": (recent_hits / recent_count) if recent_count else 0.0}
189
+
190
+ def close(self) -> None:
191
+ with self._lock:
192
+ if not self._closed:
193
+ self._db.close()
194
+ self._db = None
195
+ self._closed = True
196
+
197
+
198
+ __all__ = ["SQLiteVectorCache", "CacheCorruptionError"]