atif-sql 0.2.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 (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,115 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Find and read a session's stored JSON artifacts, compressed or plain.
4
+
5
+ :mod:`atif_duck.domain.artifacts` says which artifacts may be stored as
6
+ ``<name>.zst``; this module does the filesystem half. DuckDB's JSON readers
7
+ decompress a ``.zst`` file on their own (the compression is detected per file
8
+ from its extension), so a SQL reader only needs the right path. The Python
9
+ readers (the columnar producer's events input, the lake loader's trajectory
10
+ decode) go through :func:`read_bytes` and :func:`iter_lines`.
11
+
12
+ A zstd frame written by atif-sql records its decompressed size in the frame
13
+ header, which :func:`decoded_size` reads (18 bytes, no decompression): DuckDB's
14
+ ``maximum_object_size`` has to be sized from the document, not from the far
15
+ smaller compressed file. A frame without that field (written by another tool)
16
+ is measured by decompressing it once.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ from typing import TYPE_CHECKING
23
+
24
+ from atif_duck.domain.artifacts import COMPRESSED_SUFFIX, stored_names
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Iterator
28
+ from pathlib import Path
29
+
30
+ #: The largest zstd frame header (magic, descriptor, window, dictionary id,
31
+ #: content size): enough bytes to read the content size of any frame.
32
+ _FRAME_HEADER_MAX_BYTES: int = 18
33
+
34
+ #: Chunk size for the fallback that measures a frame by decompressing it.
35
+ _MEASURE_CHUNK_BYTES: int = 1024 * 1024
36
+
37
+
38
+ def is_compressed(path: Path) -> bool:
39
+ """True when ``path`` is a compressed artifact (by its suffix, as DuckDB decides)."""
40
+ return path.name.endswith(COMPRESSED_SUFFIX)
41
+
42
+
43
+ def stored_artifact(session_dir: Path, name: str) -> Path | None:
44
+ """The file ``name`` is stored under in ``session_dir``, or ``None`` if neither exists."""
45
+ for candidate in stored_names(name):
46
+ path = session_dir / candidate
47
+ if path.is_file():
48
+ return path
49
+ return None
50
+
51
+
52
+ def _measure(path: Path) -> int:
53
+ import zstandard
54
+
55
+ if not is_compressed(path):
56
+ return path.stat().st_size
57
+ with path.open("rb") as handle:
58
+ header = handle.read(_FRAME_HEADER_MAX_BYTES)
59
+ size = zstandard.frame_content_size(header) if header else 0
60
+ if size >= 0:
61
+ return int(size)
62
+ total = 0
63
+ with path.open("rb") as handle, zstandard.ZstdDecompressor().stream_reader(handle) as reader:
64
+ while chunk := reader.read(_MEASURE_CHUNK_BYTES):
65
+ total += len(chunk)
66
+ return total
67
+
68
+
69
+ def decoded_size(path: Path) -> int:
70
+ """The artifact's size once decompressed (a plain file's own size); 0 if unreadable."""
71
+ import zstandard
72
+
73
+ try:
74
+ return _measure(path)
75
+ except (OSError, zstandard.ZstdError):
76
+ return 0
77
+
78
+
79
+ def read_bytes(path: Path) -> bytes:
80
+ """The artifact's decompressed bytes."""
81
+ if not is_compressed(path):
82
+ return path.read_bytes()
83
+ import zstandard
84
+
85
+ with path.open("rb") as handle, zstandard.ZstdDecompressor().stream_reader(handle) as reader:
86
+ return reader.readall()
87
+
88
+
89
+ def iter_lines(path: Path) -> Iterator[str]:
90
+ """The artifact's decompressed text, line by line (a missing file yields nothing)."""
91
+ try:
92
+ raw = path.open("rb")
93
+ except FileNotFoundError:
94
+ return
95
+ with raw:
96
+ if is_compressed(path):
97
+ import zstandard
98
+
99
+ with (
100
+ zstandard.ZstdDecompressor().stream_reader(raw) as reader,
101
+ io.TextIOWrapper(reader, encoding="utf-8") as text,
102
+ ):
103
+ yield from text
104
+ else:
105
+ with io.TextIOWrapper(raw, encoding="utf-8") as text:
106
+ yield from text
107
+
108
+
109
+ __all__ = [
110
+ "decoded_size",
111
+ "is_compressed",
112
+ "iter_lines",
113
+ "read_bytes",
114
+ "stored_artifact",
115
+ ]
atif_duck/py.typed ADDED
File without changes
atif_embed/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """atif-embed: embedding provider + LanceDB vector store + embed backfill.
4
+
5
+ The VSS write path over the ATIF corpus: Cohere Embed v4 on Bedrock
6
+ (``infrastructure/cohere_bedrock.py``), a local LanceDB embeddings store
7
+ (``infrastructure/lance_store.py``), a contract-layout corpus reader behind
8
+ the :class:`~atif_embed.domain.ports.TextRowsPort`
9
+ (``infrastructure/corpus_text_rows.py``), and the backfill use case
10
+ (``application/embed.py``). Independent of every sibling package — only
11
+ atif-cli composes (import-linter independence contract).
12
+ """
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Application use cases for atif-embed: the embedding backfill."""
@@ -0,0 +1,293 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Embedding backfill use case.
4
+
5
+ Discovers steps with no CURRENT embedding (through :class:`TextRowsPort`,
6
+ against the Lance store's ``{uuid: text_hash}`` map), embeds them through the
7
+ :class:`EmbeddingProvider` port, and appends the vectors to the LanceDB store
8
+ keyed by the step's primary raw-record uuid.
9
+
10
+ The provider's ``model_id`` / ``dimension`` are stamped on every row and
11
+ enforced against the store's prior stamp (fail-loud on a provider switch:
12
+ mixing vector spaces silently corrupts kNN search). Loss is bounded at three
13
+ levels: discovery is consumed one chunk at a time, checkpoints of
14
+ ``max(batch_size * 4, 256)`` rows commit progress mid-run, and a batch that
15
+ fails terminally inside a chunk does not discard the batches around it. A
16
+ Lance ``optimize()`` runs every 8 chunks and a final optimize +
17
+ ``ensure_index`` on the way out.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from datetime import UTC, datetime
24
+ from itertools import islice
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from loguru import logger
28
+
29
+ from atif_embed.domain.embedding_guard import ensure_store_matches
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Iterator
33
+ from pathlib import Path
34
+
35
+ from atif_embed.domain.ports import EmbeddingProvider, TextRowsPort, VectorStorePort
36
+ from atif_embed.domain.text_stamp import PendingText
37
+ from atif_embed.infrastructure.settings import EmbedSettings
38
+
39
+ #: Optimize the Lance store after this many appended chunks.
40
+ _OPTIMIZE_EVERY_CHUNKS = 8
41
+
42
+
43
+ def discover_unembedded(
44
+ corpus_root: Path,
45
+ *,
46
+ text_rows: TextRowsPort,
47
+ store: VectorStorePort,
48
+ limit: int | None = None,
49
+ ) -> Iterator[PendingText]:
50
+ """Yield the texts that have no CURRENT embedding.
51
+
52
+ The store's ``{uuid: text_hash}`` map is read once (column-projected
53
+ scan) and the staleness comparison happens inside the text-rows adapter,
54
+ before the limit cap, so ``--limit N`` always makes N rows of forward
55
+ progress. Lazy: the caller consumes one write-chunk at a time.
56
+ """
57
+ embedded = store.get_embedded_hashes()
58
+ return text_rows.iter_unembedded(corpus_root, embedded=embedded, limit=limit)
59
+
60
+
61
+ async def run_backfill(
62
+ *,
63
+ corpus_root: Path,
64
+ settings: EmbedSettings,
65
+ embedder: EmbeddingProvider | None = None,
66
+ text_rows: TextRowsPort | None = None,
67
+ store: VectorStorePort | None = None,
68
+ limit: int | None = None,
69
+ dry_run: bool = False,
70
+ ) -> int | dict[str, Any]:
71
+ """Discover unembedded steps, embed them, and append to the Lance store.
72
+
73
+ A run that embedded every row its discovery yielded, and whose discovery
74
+ ran to its end, calls ``text_rows.commit`` with the store's row count, so
75
+ a reader with a watermark (the lake reader) can start the next run from
76
+ there. A dry run never commits.
77
+
78
+ Parameters
79
+ ----------
80
+ corpus_root
81
+ Materialized corpus root (the directory containing ``sessions/``).
82
+ settings
83
+ Embedding settings (model, batch size, concurrency, lance uri).
84
+ embedder
85
+ Optional :class:`EmbeddingProvider`; defaults to the provider
86
+ ``settings.embed_provider`` selects (imported lazily so dry runs never
87
+ load boto3 or torch).
88
+ text_rows
89
+ Optional :class:`TextRowsPort`; defaults to the contract-layout
90
+ DuckDB reader.
91
+ store
92
+ Optional :class:`VectorStorePort`; defaults to the Lance-backed store
93
+ over ``settings.lance_uri``.
94
+ limit
95
+ Optional cap on the number of steps embedded this run.
96
+ dry_run
97
+ If true, log the plan and return a plan dict without embedding calls.
98
+
99
+ Returns
100
+ -------
101
+ int | dict
102
+ Under ``dry_run=True``, a plan dict with ``{pipeline, discovery,
103
+ candidates, batches, batch_size, concurrency, provider, model, dim,
104
+ store, limit, dry_run}``.
105
+ Otherwise, count of newly written rows (0 when nothing is pending).
106
+ """
107
+ import polars as pl
108
+
109
+ lance_uri = settings.resolve_lance_uri(corpus_root)
110
+ if text_rows is None:
111
+ from atif_embed.infrastructure.corpus_text_rows import DuckDbTextRows
112
+
113
+ text_rows = DuckDbTextRows()
114
+ if store is None:
115
+ from atif_embed.infrastructure.lance_store import LanceVectorStore
116
+
117
+ store = LanceVectorStore(lance_uri, dim=settings.embedding_dim)
118
+
119
+ plan_model = settings.expected_embedding_identity()[0]
120
+ batch_size = settings.active_batch_size
121
+ stored = store.get_embedded_hashes()
122
+ pending = text_rows.iter_unembedded(corpus_root, embedded=stored, limit=limit)
123
+
124
+ if dry_run:
125
+ candidates = sum(1 for _ in pending)
126
+ n_batches = (candidates + batch_size - 1) // batch_size
127
+ if candidates == 0:
128
+ logger.info("No unembedded steps found - nothing to do")
129
+ else:
130
+ logger.info(
131
+ "Backfill plan: {} steps, {} batches, concurrency={}, model={}; "
132
+ "dry_run=True - skipping embedding calls",
133
+ candidates,
134
+ n_batches,
135
+ settings.active_concurrency,
136
+ plan_model,
137
+ )
138
+ return {
139
+ "pipeline": "embed",
140
+ "discovery": text_rows.discovery,
141
+ "candidates": candidates,
142
+ "batches": n_batches,
143
+ "batch_size": batch_size,
144
+ "concurrency": settings.active_concurrency,
145
+ "provider": settings.embed_provider,
146
+ "model": plan_model,
147
+ "dim": settings.embedding_dim,
148
+ "store": str(lance_uri),
149
+ "limit": limit,
150
+ "dry_run": True,
151
+ }
152
+
153
+ # Chunk must be a multiple of batch_size so a checkpoint boundary never
154
+ # splits a provider batch.
155
+ chunk_size = max(batch_size * 4, 256)
156
+ rows = iter(pending)
157
+ first_chunk = list(islice(rows, chunk_size))
158
+ if not first_chunk:
159
+ logger.info("No unembedded steps found - nothing to do")
160
+ text_rows.commit(stored_rows=len(stored))
161
+ return 0
162
+
163
+ # Build the provider once for the whole run. dimension / model_id become
164
+ # the single contract source. Deferred import keeps boto3 and torch off
165
+ # the dry-run / nothing-pending paths above, which return before this point.
166
+ if embedder is None:
167
+ from atif_embed.infrastructure.providers import build_embedder
168
+
169
+ embedder = build_embedder(settings)
170
+ model_id = embedder.model_id
171
+ dim = embedder.dimension
172
+
173
+ # Fail-loud guard: if the store was written by a different provider/model,
174
+ # refuse to append into it (mixing vector spaces silently corrupts kNN).
175
+ identity = store.table_identity()
176
+ if identity is not None:
177
+ stored_model, stored_dim = identity
178
+ ensure_store_matches(
179
+ stored_model=stored_model,
180
+ stored_dim=stored_dim,
181
+ expected_model=model_id,
182
+ expected_dim=dim,
183
+ )
184
+
185
+ total_t0 = time.monotonic()
186
+ written = 0
187
+ skipped = 0
188
+ chunk_index = 0
189
+ chunks_since_optimize = 0
190
+ chunk: list[PendingText] | None = first_chunk
191
+ while chunk:
192
+ chunk_index += 1
193
+ logger.info("Chunk {}: embedding {} steps", chunk_index, len(chunk))
194
+ t0 = time.monotonic()
195
+ vectors = await embedder.embed_documents([p.text for p in chunk])
196
+ elapsed = time.monotonic() - t0
197
+
198
+ embedded = [
199
+ (pending_text, vector)
200
+ for pending_text, vector in zip(chunk, vectors, strict=True)
201
+ if vector is not None
202
+ ]
203
+ failed = len(chunk) - len(embedded)
204
+ skipped += failed
205
+ logger.info(
206
+ "Chunk done in {:.1f}s ({:.1f} vec/s){}",
207
+ elapsed,
208
+ len(embedded) / elapsed if elapsed > 0 else 0.0,
209
+ f"; {failed} rows failed and stay unembedded for the next run" if failed else "",
210
+ )
211
+
212
+ if embedded:
213
+ # Replace before append: a stale row under the same uuid would
214
+ # otherwise sit beside the new vector and fan the kNN join out
215
+ # across both.
216
+ stale = [p.uuid for p, _ in embedded if p.replaces_existing]
217
+ if stale:
218
+ deleted = store.delete_uuids(stale)
219
+ logger.info("Replacing {} stale rows whose source text changed", deleted)
220
+ now = datetime.now(UTC)
221
+ # Force a fixed-size Array so to_arrow() produces
222
+ # pa.list_(pa.float32, dim) — what Lance requires for vector
223
+ # columns. A regular pl.List becomes a variable-size list and
224
+ # Lance rejects it for indexing.
225
+ df = pl.DataFrame(
226
+ {
227
+ "uuid": [p.uuid for p, _ in embedded],
228
+ "model": [model_id] * len(embedded),
229
+ "dim": [dim] * len(embedded),
230
+ "embedding": [v for _, v in embedded],
231
+ "embedded_at": [now] * len(embedded),
232
+ "text_hash": [p.text_hash for p, _ in embedded],
233
+ "truncated": [p.truncated for p, _ in embedded],
234
+ },
235
+ schema={
236
+ "uuid": pl.Utf8,
237
+ "model": pl.Utf8,
238
+ "dim": pl.Int32,
239
+ "embedding": pl.Array(pl.Float32, dim),
240
+ "embedded_at": pl.Datetime("us", "UTC"),
241
+ "text_hash": pl.Utf8,
242
+ "truncated": pl.Boolean,
243
+ },
244
+ )
245
+ store.add_chunk(df)
246
+ written += len(embedded)
247
+ chunks_since_optimize += 1
248
+ logger.info("Checkpoint: {} rows -> {}", len(df), lance_uri)
249
+
250
+ # Compact periodically so fragment count stays bounded during long
251
+ # backfills.
252
+ if chunks_since_optimize >= _OPTIMIZE_EVERY_CHUNKS:
253
+ store.optimize()
254
+ chunks_since_optimize = 0
255
+
256
+ chunk = list(islice(rows, chunk_size))
257
+
258
+ # Final compaction + index ensure on the way out so the search command
259
+ # sees an up-to-date index without paying brute-force scan latency.
260
+ store.optimize()
261
+ store.ensure_index(metric=settings.hnsw_metric)
262
+
263
+ # A watermark may only move past rows that are all stored: a failed row
264
+ # has to be found again next run, and a run cut short by --limit never
265
+ # read the rest (the reader itself checks that second half).
266
+ if skipped == 0:
267
+ text_rows.commit(stored_rows=len(store.get_embedded_hashes()))
268
+
269
+ total_elapsed = time.monotonic() - total_t0
270
+ logger.info(
271
+ "Backfill complete: {} embeddings in {:.1f}s ({:.1f} vec/s overall){}",
272
+ written,
273
+ total_elapsed,
274
+ written / total_elapsed if total_elapsed > 0 else 0.0,
275
+ f"; {skipped} rows failed and remain unembedded" if skipped else "",
276
+ )
277
+ return written
278
+
279
+
280
+ def embed_query(text: str, *, settings: EmbedSettings) -> list[float]:
281
+ """Embed a single query string for nearest-neighbor search.
282
+
283
+ Thin shim over the selected provider (imported lazily): returns a float
284
+ query vector of length ``settings.embedding_dim``. Cohere forces ``float``
285
+ for queries even when documents were stored int8; EmbeddingGemma 2 embeds
286
+ under its ``SearchQuery`` prompt.
287
+ """
288
+ from atif_embed.infrastructure.providers import build_embedder
289
+
290
+ return build_embedder(settings).embed_query(text)
291
+
292
+
293
+ __all__ = ["discover_unembedded", "embed_query", "run_backfill"]
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Find, and on request delete, the stored vectors no lake step names.
4
+
5
+ A row in the Lance store is an orphan when its uuid is the primary uuid of no
6
+ step the lake holds for the corpus: its session was deleted before the corpus
7
+ kept removed sessions, or a converter change re-keyed the step. Search joins
8
+ hits to steps, so an orphan never shows up in a result, but it still takes
9
+ space and still competes for the top-k before the join drops it.
10
+
11
+ The lake is the reference because it holds every session the corpus holds,
12
+ retained ones included. The caller must make sure no session is still
13
+ waiting for a lake write (atif-cli checks the corpus's pending list), since
14
+ such a session's vectors would look orphaned until its rows land.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from loguru import logger
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+ from atif_embed.domain.ports import LakeStepsPort, VectorStorePort
27
+
28
+ #: uuids per Lance delete predicate.
29
+ _DELETE_CHUNK = 1000
30
+
31
+
32
+ def prune_orphans(
33
+ corpus_root: Path,
34
+ *,
35
+ lake: LakeStepsPort,
36
+ store: VectorStorePort,
37
+ dry_run: bool = True,
38
+ ) -> dict[str, Any] | None:
39
+ """Count the store's orphan rows, and delete them unless ``dry_run``.
40
+
41
+ Returns ``None`` when there is no lake to check against. Otherwise
42
+ ``{pipeline, stored, lake_keys, orphans, deleted, dry_run}``. A corpus
43
+ the lake holds no steps for is refused (nothing deleted, ``orphans``
44
+ still counted) rather than read as "every row is an orphan".
45
+ """
46
+ snapshot = lake.open_steps(corpus_root)
47
+ if snapshot is None:
48
+ return None
49
+ with snapshot:
50
+ keys = snapshot.step_keys()
51
+ position = snapshot.position
52
+ stored = store.get_embedded_hashes()
53
+ orphans = sorted(uuid for uuid in stored if uuid not in keys)
54
+ report: dict[str, Any] = {
55
+ "pipeline": "prune_orphans",
56
+ "lake_snapshot": position.snapshot_id,
57
+ "stored": len(stored),
58
+ "lake_keys": len(keys),
59
+ "orphans": len(orphans),
60
+ "deleted": 0,
61
+ "dry_run": dry_run,
62
+ }
63
+ if not keys:
64
+ logger.warning(
65
+ "prune: the lake holds no steps for {}; refusing to treat every stored row as an orphan",
66
+ corpus_root,
67
+ )
68
+ report["refused"] = "the lake holds no steps for this corpus"
69
+ return report
70
+ if dry_run or not orphans:
71
+ return report
72
+ for start in range(0, len(orphans), _DELETE_CHUNK):
73
+ store.delete_uuids(orphans[start : start + _DELETE_CHUNK])
74
+ store.optimize()
75
+ report["deleted"] = len(orphans)
76
+ logger.info("prune: deleted {} orphan rows from the store", len(orphans))
77
+ return report
78
+
79
+
80
+ __all__ = ["prune_orphans"]
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Pure domain rules for atif-embed: ports, errors, and the identity guard."""
@@ -0,0 +1,70 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Turn ordered ``(uuid, text)`` step rows into the texts that need embedding.
4
+
5
+ Every discovery source (the per-session trajectory reader and the lake reader)
6
+ yields the same thing: the corpus's embeddable step rows in corpus order,
7
+ ``(primary uuid, flattened text)``. What happens next is one rule set, kept
8
+ here so the two sources can't drift apart:
9
+
10
+ * the first row for a uuid wins, so the store never gets two vectors for one
11
+ key (a resumed session can repeat an earlier session's records);
12
+ * a uuid whose stored ``text_hash`` equals the current text's hash is already
13
+ embedded and is skipped;
14
+ * a uuid stored under a different hash is STALE and comes back with
15
+ ``replaces_existing=True``;
16
+ * ``limit`` caps the rows yielded, and it's applied AFTER the store
17
+ comparison, so ``--limit N`` always makes N rows of progress.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ from atif_embed.domain.text_stamp import PendingText, text_hash
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Iterable, Iterator
28
+
29
+
30
+ class PendingSelection:
31
+ """One pass of the selection rules over one stream of step rows.
32
+
33
+ :attr:`exhausted` turns true only when the stream ran out before the
34
+ limit did. A caller that keeps a watermark reads it to tell a complete
35
+ pass from one ``--limit`` cut short.
36
+ """
37
+
38
+ def __init__(self, *, embedded: dict[str, str] | None, limit: int | None) -> None:
39
+ self._embedded = embedded or {}
40
+ self._limit = limit
41
+ self.exhausted = False
42
+ self.yielded = 0
43
+
44
+ def select(self, rows: Iterable[tuple[str, str]]) -> Iterator[PendingText]:
45
+ """Yield the rows that need embedding, lazily."""
46
+ limit = self._limit
47
+ if limit is not None and limit <= 0:
48
+ return
49
+ seen: set[str] = set()
50
+ for uuid, text in rows:
51
+ if uuid in seen:
52
+ continue
53
+ seen.add(uuid)
54
+ stamp = text_hash(text)
55
+ stored = self._embedded.get(uuid)
56
+ if stored == stamp:
57
+ continue
58
+ self.yielded += 1
59
+ yield PendingText(
60
+ uuid=uuid,
61
+ text=text,
62
+ text_hash=stamp,
63
+ replaces_existing=stored is not None,
64
+ )
65
+ if limit is not None and self.yielded >= limit:
66
+ return
67
+ self.exhausted = True
68
+
69
+
70
+ __all__ = ["PendingSelection"]
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The fail-loud embedding provider/dimension guard (pure domain rule).
4
+
5
+ Every embeddings-store row is stamped with the embedder's ``model_id`` and
6
+ ``dimension``, and both the write path
7
+ (:func:`atif_embed.application.embed.run_backfill`) and the read/bind path
8
+ (``atif_duck.infrastructure.registry.register_vss``, via the caller-supplied
9
+ ``expected_model`` / ``expected_dim``) read that stamp back and call
10
+ :func:`ensure_store_matches` before touching vectors. A provider switch is
11
+ destructive (different models live in incompatible vector spaces even at
12
+ matching widths), so the guard fails loud rather than silently corrupting
13
+ the kNN index.
14
+
15
+ DELIBERATE TWIN of ``atif_duck.domain.embedding_guard``: atif-duck may never
16
+ import atif-embed (independence contract) and this is a tiny pure rule, so
17
+ each package carries its own copy. A behavioral change in one — including
18
+ the recovery message — must be ported to the other.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from atif_embed.domain.errors import EmbeddingProviderMismatch
24
+
25
+ #: Recovery instruction appended to the mismatch message. The store lives at
26
+ #: ``<corpus_root>/embeddings_lance`` by default (see
27
+ #: ``EmbedSettings.resolve_lance_uri``), NOT under a fixed home directory —
28
+ #: naming a path the store isn't at makes the operator delete nothing and
29
+ #: stay broken. Kept identical to atif-duck's twin copy.
30
+ RECOVERY_HINT = (
31
+ "Re-embed under the new provider: rm -rf the Lance store directory "
32
+ "(ATIF_SQL_LANCE_URI, or <corpus-root>/embeddings_lance by default) "
33
+ "and re-run `atif-sql embed --all --no-dry-run` (a bare `atif-sql embed` "
34
+ "exits 64: a real run needs an explicit scope)."
35
+ )
36
+
37
+
38
+ def ensure_store_matches(
39
+ *,
40
+ stored_model: str | None,
41
+ stored_dim: int | None,
42
+ expected_model: str,
43
+ expected_dim: int | None,
44
+ ) -> None:
45
+ """Fail loud if a store's stamped identity differs from the active embedder.
46
+
47
+ ``stored_model`` / ``stored_dim`` come from the Lance ``model`` / ``dim``
48
+ columns (both stamped on every row). ``None`` for either means the store
49
+ is empty (fresh install) and any provider may claim it, so the check is a
50
+ no-op.
51
+
52
+ ``model_id`` is the primary identity: it is globally unique and encodes
53
+ the provider + model, so a match guarantees a compatible vector space.
54
+ Cohere is the one provider whose single ``model_id`` can emit different
55
+ Matryoshka widths, so ``expected_dim`` is also checked when supplied;
56
+ ``expected_dim=None`` trusts ``model_id`` alone.
57
+
58
+ On a genuine mismatch this raises :class:`EmbeddingProviderMismatch`
59
+ naming both sides and the exact recovery command.
60
+ """
61
+ if stored_model is None or stored_dim is None:
62
+ return
63
+ model_ok = stored_model == expected_model
64
+ dim_ok = expected_dim is None or stored_dim == expected_dim
65
+ if model_ok and dim_ok:
66
+ return
67
+ # The message stays INSIDE the raise: `test_guard_twin_pin.py` reads both
68
+ # twin modules as source text and requires `{RECOVERY_HINT}` to appear after
69
+ # the `raise EmbeddingProviderMismatch` keyword, which is what proves the
70
+ # shared constant reaches the operator rather than only sitting in the
71
+ # module. Hoisting the message to a local satisfies EM102 / TRY003 and
72
+ # defeats that pin, so both are suppressed here instead.
73
+ raise EmbeddingProviderMismatch( # noqa: TRY003
74
+ "Embedding store was written by a different provider/model. " # noqa: EM102
75
+ f"stored=(model={stored_model!r}, dim={stored_dim}) "
76
+ f"active=(model={expected_model!r}, dim={expected_dim}). "
77
+ "Vectors from different models live in incompatible spaces (even at "
78
+ "matching dimensions), so mixing them silently corrupts kNN search. "
79
+ f"{RECOVERY_HINT}"
80
+ )
81
+
82
+
83
+ __all__ = ["RECOVERY_HINT", "ensure_store_matches"]