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,273 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Contract-layout corpus reader behind :class:`TextRowsPort`.
4
+
5
+ atif-embed may never import atif-duck (import-linter independence contract),
6
+ so this adapter reads the CONTRACT corpus layout
7
+ (``<corpus_root>/sessions/<id>/trajectory.json``) directly with its OWN
8
+ DuckDB connection + ``read_json`` — the same move atif-corpus made with
9
+ ``ConverterPort``: depend on the contract's artifact shapes, not on a
10
+ sibling package's registry. The trajectory paths reach ``read_json`` as a
11
+ bound list parameter, never as statement text.
12
+
13
+ Selection semantics (CONTRACT-V2 §VSS):
14
+
15
+ * the embeddable unit is a step's flattened text (``str | ContentPart[]``,
16
+ the ARRAY branch joined with blank lines — mirrors atif-duck's ``steps``
17
+ view and harbor's own text bundling);
18
+ * main chain AND sidechain steps are included (no ``is_sidechain`` filter);
19
+ * only texts of >= 32 characters qualify — shorter step texts are acks and
20
+ noise;
21
+ * the row key is the step's PRIMARY uuid: the FIRST ``extra.source_uuids``
22
+ entry. Harbor bundles all raw records sharing an assistant ``message.id``
23
+ into one step, so a step maps to 1..n raw uuids; the first entry is the
24
+ bundle's head record, which is the uuid the ``messages`` view (edges.jsonl)
25
+ can join back to. Steps without source uuids are skipped — they cannot be
26
+ keyed against the raw-record surface.
27
+
28
+ Torn-set guard: like atif-duck's raw readers, only session dirs with a
29
+ ``meta.json`` (written last by atif-corpus) contribute rows.
30
+
31
+ Stored names: materialize stores the trajectory compressed, as
32
+ ``trajectory.json.zst``; a corpus written before that keeps the plain
33
+ ``trajectory.json`` until ``atif-sql corpus slim`` compresses it. Each
34
+ session's stored file is resolved once (compressed first) and DuckDB
35
+ decompresses it, so the rows are the same either way. Batches are sized by
36
+ the decompressed bytes, which is what a statement holds.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import TYPE_CHECKING
42
+
43
+ from loguru import logger
44
+
45
+ from atif_embed.domain.discovery import PendingSelection
46
+
47
+ if TYPE_CHECKING:
48
+ from collections.abc import Iterator
49
+ from pathlib import Path
50
+
51
+ from atif_embed.domain.text_stamp import PendingText
52
+
53
+ #: Minimum characters for a step text to be worth embedding.
54
+ MIN_TEXT_CHARS = 32
55
+
56
+ #: ``read_json`` upper bound — live trajectory.json files reach 85 MB
57
+ #: (harbor inlines subagent sidechains); 1 GiB gives ~10x headroom.
58
+ _MAX_OBJECT_SIZE = 1_073_741_824
59
+
60
+ #: The trajectory's name, and the suffix it carries when stored compressed
61
+ #: (twins of atif-corpus's, pinned in atif-cli's tests).
62
+ TRAJECTORY_FILENAME = "trajectory.json"
63
+ COMPRESSED_SUFFIX = ".zst"
64
+
65
+ #: The largest zstd frame header: enough bytes to read any frame's content size.
66
+ _FRAME_HEADER_MAX_BYTES = 18
67
+
68
+ #: Rows pulled off a DuckDB result per ``fetchmany``.
69
+ _FETCH_PAGE_ROWS = 512
70
+
71
+ #: Trajectory BYTES a single DuckDB statement may read. Residency scales with
72
+ #: the bytes a statement materializes, not with the file count, so batching by
73
+ #: bytes is what makes both ends bounded: thousands of small sessions are read
74
+ #: together (few statements) while a session above the budget gets a statement
75
+ #: to itself (peak stays near that one session). See this package's README for
76
+ #: the measured curve across corpus shapes.
77
+ _BATCH_MAX_BYTES = 4 * 1024 * 1024
78
+
79
+
80
+ def _step_texts_sql() -> str:
81
+ """SQL selecting ``(uuid, text_content)`` from ONE BATCH of trajectories.
82
+
83
+ The batch's paths are NOT in this text: ``read_json(?)`` takes them as one
84
+ bound list parameter at execute time, so a path is data to DuckDB and
85
+ never part of the statement. The only interpolations are the two integer
86
+ constants below.
87
+
88
+ One trajectory document per file -> ``format='auto'`` (NOT NDJSON). The
89
+ explicit ``columns`` projection is a strict filter AND it skips JSON
90
+ schema inference, the dominant cost on a large corpus. The flattened-text
91
+ CASE lives once in a CTE so the >=32-char floor and the projection read
92
+ the same expression.
93
+
94
+ ``filename=true`` plus ``ORDER BY filename, step_id`` keeps row order
95
+ identical to reading the batch's files one at a time, so batch boundaries
96
+ cannot change which rows a ``--limit`` run picks. The two spellings of a
97
+ session's file differ only after its directory name, so a corpus mixing
98
+ them sorts in the same session order.
99
+ """
100
+ return f"""
101
+ WITH step_texts AS (
102
+ SELECT
103
+ t.filename AS filename,
104
+ json_extract(step, '$.step_id')::BIGINT AS step_id,
105
+ json_extract_string(step, '$.extra.source_uuids[0]') AS uuid,
106
+ CASE WHEN json_type(step, '$.message') = 'ARRAY'
107
+ THEN array_to_string(
108
+ list_transform(
109
+ json_extract(step, '$.message[*].text'),
110
+ part -> json_extract_string(part, '$')
111
+ ),
112
+ '\n\n'
113
+ )
114
+ ELSE json_extract_string(step, '$.message')
115
+ END AS text_content
116
+ FROM read_json(
117
+ ?,
118
+ format='auto',
119
+ filename=true,
120
+ columns={{steps: 'JSON[]'}},
121
+ maximum_object_size={_MAX_OBJECT_SIZE}
122
+ ) t,
123
+ UNNEST(t.steps) AS s(step)
124
+ )
125
+ SELECT uuid, text_content
126
+ FROM step_texts
127
+ WHERE uuid IS NOT NULL
128
+ AND text_content IS NOT NULL
129
+ AND length(text_content) >= {MIN_TEXT_CHARS}
130
+ ORDER BY filename, step_id
131
+ """ # noqa: S608 # nosec B608 - paths are a bound parameter; both limits are int constants
132
+
133
+
134
+ def _decoded_size(path: Path) -> int:
135
+ """The trajectory's size once decompressed: what a statement reading it holds."""
136
+ if not path.name.endswith(COMPRESSED_SUFFIX):
137
+ return path.stat().st_size
138
+ import zstandard
139
+
140
+ with path.open("rb") as handle:
141
+ size = zstandard.frame_content_size(handle.read(_FRAME_HEADER_MAX_BYTES))
142
+ # A frame without a recorded size (not written by atif-sql): its stored
143
+ # size is the only cheap stand-in, and it merely under-fills a batch.
144
+ return int(size) if size >= 0 else path.stat().st_size
145
+
146
+
147
+ def _stored_trajectory(session_dir: Path) -> Path | None:
148
+ for name in (f"{TRAJECTORY_FILENAME}{COMPRESSED_SUFFIX}", TRAJECTORY_FILENAME):
149
+ path = session_dir / name
150
+ if path.is_file():
151
+ return path
152
+ return None
153
+
154
+
155
+ def _complete_trajectory_paths(corpus_root: Path) -> list[tuple[str, int]]:
156
+ """``(path, decoded size)`` for every COMPLETE session dir (meta.json present)."""
157
+ sessions_dir = corpus_root / "sessions"
158
+ if not sessions_dir.is_dir():
159
+ return []
160
+ paths: list[tuple[str, int]] = []
161
+ for session_dir in sorted(sessions_dir.iterdir()):
162
+ if not session_dir.is_dir():
163
+ continue
164
+ if not (session_dir / "meta.json").is_file():
165
+ logger.warning(
166
+ "Skipping incomplete session dir {} (no meta.json); excluded from embed",
167
+ session_dir,
168
+ )
169
+ continue
170
+ trajectory = _stored_trajectory(session_dir)
171
+ if trajectory is not None:
172
+ paths.append((str(trajectory), _decoded_size(trajectory)))
173
+ return paths
174
+
175
+
176
+ def _batch_by_bytes(paths: list[tuple[str, int]], *, max_bytes: int) -> list[list[str]]:
177
+ """Group ``(path, size)`` pairs into batches of at most ``max_bytes``.
178
+
179
+ Order is preserved, so batching cannot reorder rows. A file at or above
180
+ the budget forms a batch of one rather than being dropped or merged: the
181
+ budget is a target, and the true residency floor is the largest single
182
+ session, which no grouping can lower.
183
+ """
184
+ batches: list[list[str]] = []
185
+ current: list[str] = []
186
+ current_bytes = 0
187
+ for path, size in paths:
188
+ if current and current_bytes + size > max_bytes:
189
+ batches.append(current)
190
+ current = []
191
+ current_bytes = 0
192
+ current.append(path)
193
+ current_bytes += size
194
+ if current:
195
+ batches.append(current)
196
+ return batches
197
+
198
+
199
+ class DuckDbTextRows:
200
+ """:class:`~atif_embed.domain.ports.TextRowsPort` over the contract corpus."""
201
+
202
+ #: This reader has one path: every complete session's trajectory.
203
+ discovery = "corpus"
204
+
205
+ def iter_unembedded(
206
+ self,
207
+ corpus_root: Path,
208
+ *,
209
+ embedded: dict[str, str] | None = None,
210
+ limit: int | None = None,
211
+ ) -> Iterator[PendingText]:
212
+ """Yield the texts needing embedding, one byte-bounded batch at a time.
213
+
214
+ DuckDB materializes a result set fully inside ``execute()``, so a
215
+ single query over every trajectory path holds the whole corpus's text
216
+ regardless of how it is paged off the result; splitting into
217
+ statements is what bounds the peak, and ``fetchmany`` bounds the
218
+ Python-side row buffer within a batch.
219
+
220
+ The split is by total file BYTES
221
+ (:data:`_BATCH_MAX_BYTES`), not per file. Residency tracks the bytes
222
+ one statement reads, while wall time tracks the statement COUNT, so
223
+ bytes is the axis that bounds both: a corpus of thousands of small
224
+ sessions becomes a few statements instead of thousands, and a session
225
+ larger than the budget still gets a statement to itself. Peak resident
226
+ text is therefore the batch budget or the largest single session,
227
+ whichever is larger.
228
+
229
+ The staleness join against ``embedded`` (the store's
230
+ ``{uuid: text_hash}`` map) happens in Python BEFORE ``limit`` is
231
+ applied. Ordering matters: filtering first guarantees ``--limit N``
232
+ always makes N rows of forward progress, whereas a SQL LIMIT ahead
233
+ of the filter spends the cap on rows that are already embedded and a
234
+ bounded run can make ZERO progress. At corpus scale the comparison
235
+ is cheap. A uuid whose stored hash differs from the current text is
236
+ STALE and is re-yielded with ``replaces_existing=True``.
237
+
238
+ Rows are deterministic: ordered by trajectory path then step_id, and
239
+ de-duplicated on uuid (first occurrence wins) so the Lance store
240
+ never receives two vectors for one key.
241
+ """
242
+ paths = _complete_trajectory_paths(corpus_root)
243
+ if not paths:
244
+ logger.info("No complete sessions under {} — nothing to embed", corpus_root)
245
+ return
246
+ selection = PendingSelection(embedded=embedded, limit=limit)
247
+ yield from selection.select(_step_rows(paths))
248
+
249
+ def commit(self, *, stored_rows: int) -> None:
250
+ """Nothing to record: this reader has no watermark and re-reads every session."""
251
+ del stored_rows
252
+
253
+
254
+ def _step_rows(paths: list[tuple[str, int]]) -> Iterator[tuple[str, str]]:
255
+ """``(uuid, text)`` for every qualifying step, one byte-bounded batch per statement.
256
+
257
+ A generator, so a consumer that stops early (``--limit``) stops the
258
+ reads too: no batch past the one it's in is ever executed.
259
+ """
260
+ import duckdb
261
+
262
+ con = duckdb.connect(":memory:")
263
+ try:
264
+ for batch in _batch_by_bytes(paths, max_bytes=_BATCH_MAX_BYTES):
265
+ result = con.execute(_step_texts_sql(), [batch])
266
+ while page := result.fetchmany(_FETCH_PAGE_ROWS):
267
+ for row in page:
268
+ yield str(row[0]), str(row[1])
269
+ finally:
270
+ con.close()
271
+
272
+
273
+ __all__ = ["MIN_TEXT_CHARS", "DuckDbTextRows"]
@@ -0,0 +1,355 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """EmbeddingGemma 2 on this machine: the local ``EmbeddingProvider`` adapter.
4
+
5
+ Loads ``google/embeddinggemma-2`` through sentence-transformers in its
6
+ text-only setup (``config_kwargs`` drops the vision and audio encoders, so
7
+ 270M of the model's 740M parameters load), at a pinned repository revision.
8
+ Documents encode under the model's ``Document`` prompt (``title: none |
9
+ text: ``) and queries under ``SearchQuery`` (``task: search result | query:
10
+ ``), truncated to the configured Matryoshka width and re-normalized.
11
+
12
+ Numerical precision follows the model card: bfloat16 on a CUDA device that
13
+ supports it, float32 everywhere else, and never float16, whose range the
14
+ model's activations exceed (it returns NaN or degraded vectors without
15
+ raising). A non-finite vector is dropped as a failed slot rather than stored.
16
+
17
+ The heavy libraries (torch, transformers, sentence-transformers) come from
18
+ the optional ``local`` extra and are imported by name at load time, never at
19
+ module scope: the default Cohere path, CI, and both type checkers run without
20
+ them. Selecting this provider without the extra raises
21
+ :class:`~atif_embed.domain.errors.EmbeddingProviderNotInstalled`.
22
+
23
+ The model downloads once into the Hugging Face cache (about 1.5 GB; the
24
+ checkpoint is one file even when only the text tower loads) and later runs
25
+ read it from there; ``HF_HUB_OFFLINE=1`` keeps every run off the network.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import importlib
32
+ import math
33
+ import threading
34
+ import time
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ from loguru import logger
38
+
39
+ from atif_embed.domain.errors import (
40
+ EmbeddingProviderNotInstalled,
41
+ EmbeddingProviderUnavailable,
42
+ EmbeddingResponseInvalid,
43
+ )
44
+ from atif_embed.domain.text_stamp import MAX_EMBEDDABLE_CHARS, clip_text
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Callable
48
+
49
+ from atif_embed.infrastructure.settings import EmbedSettings
50
+
51
+ #: sentence-transformers prompt names from the model's own
52
+ #: ``config_sentence_transformers.json``: the corpus side and the query side
53
+ #: of its asymmetric search task.
54
+ DOCUMENT_PROMPT = "Document"
55
+ QUERY_PROMPT = "SearchQuery"
56
+
57
+ #: ``AutoConfig`` overrides that load the text tower alone. Text-only and
58
+ #: full-model embeddings share one vector space, so the stamped model id
59
+ #: carries no mode suffix.
60
+ TEXT_ONLY_CONFIG: dict[str, None] = {"vision_config": None, "audio_config": None}
61
+
62
+ #: The model's context window. The checkpoint's tokenizer declares no
63
+ #: maximum (sentence-transformers reads it as 10**30), so without this cap a
64
+ #: 50,000-character step would run past the window the model was trained on.
65
+ MAX_TOKENS = 8192
66
+
67
+ #: How to get the extra, named in the error a missing install raises.
68
+ INSTALL_HINT = (
69
+ "install the local extra: `uv sync --all-packages --extra local` in a checkout, or "
70
+ "`uv tool install 'atif-sql[local]' --torch-backend cpu`; or unset "
71
+ "ATIF_SQL_EMBED_PROVIDER to embed with Cohere on Bedrock"
72
+ )
73
+
74
+ #: What a forward pass can raise that one batch's loss should absorb: torch
75
+ #: raises RuntimeError (its out-of-memory error included), tokenizers raise
76
+ #: ValueError, and a device fault surfaces as OSError.
77
+ _BATCH_ERRORS = (RuntimeError, ValueError, OSError)
78
+
79
+ #: Padded characters one forward pass may hold: its text count times its
80
+ #: longest text. A batch pads every text to its longest, and attention memory
81
+ #: grows with the square of that length, so 32 texts near the model's
82
+ #: 8,192-token window took 26 GB of RSS on CPU while short texts batch freely.
83
+ #: At about 4 characters a token this is one full-window text per pass.
84
+ BATCH_CHAR_BUDGET = 32_000
85
+
86
+
87
+ def plan_batches(lengths: list[int], *, max_texts: int) -> list[list[int]]:
88
+ """Group text positions shortest first into batches a forward pass can hold.
89
+
90
+ A batch closes at ``max_texts`` texts, or when the next (longer) text
91
+ would push ``texts x longest`` past :data:`BATCH_CHAR_BUDGET`. A text
92
+ over the budget on its own still gets a batch of one.
93
+ """
94
+ batches: list[list[int]] = []
95
+ current: list[int] = []
96
+ for position in sorted(range(len(lengths)), key=lambda i: lengths[i]):
97
+ if current and (
98
+ len(current) >= max_texts or (len(current) + 1) * lengths[position] > BATCH_CHAR_BUDGET
99
+ ):
100
+ batches.append(current)
101
+ current = []
102
+ current.append(position)
103
+ if current:
104
+ batches.append(current)
105
+ return batches
106
+
107
+
108
+ def _import(name: str) -> Any:
109
+ """Import one module of the ``local`` extra, or say how to install it.
110
+
111
+ By name rather than as a statement: the extra is optional, and the type
112
+ checkers and CI run without it, so a static import would not resolve.
113
+ """
114
+ try:
115
+ return importlib.import_module(name)
116
+ except ImportError as exc:
117
+ msg = f"the gemma embedding provider needs {name}, which is not installed; {INSTALL_HINT}"
118
+ raise EmbeddingProviderNotInstalled(msg) from exc
119
+
120
+
121
+ def pick_device(requested: str, torch: Any) -> str:
122
+ """The device to load on: ``requested``, or for ``auto`` CUDA, then MPS, then CPU."""
123
+ if requested != "auto":
124
+ return requested
125
+ if torch.cuda.is_available():
126
+ return "cuda"
127
+ mps = getattr(torch.backends, "mps", None)
128
+ if mps is not None and mps.is_available():
129
+ return "mps"
130
+ return "cpu"
131
+
132
+
133
+ def pick_dtype(device: str, torch: Any) -> Any:
134
+ """bfloat16 on a CUDA device that supports it, else float32; never float16."""
135
+ if device.startswith("cuda") and torch.cuda.is_bf16_supported():
136
+ return torch.bfloat16
137
+ return torch.float32
138
+
139
+
140
+ def load_sentence_transformer(settings: EmbedSettings) -> Any:
141
+ """Load EmbeddingGemma 2 text-only at the pinned revision, on the chosen device.
142
+
143
+ Raises
144
+ ------
145
+ EmbeddingProviderNotInstalled
146
+ torch or sentence-transformers is not installed.
147
+ EmbeddingProviderUnavailable
148
+ The model could not be fetched or loaded (no network and no cached
149
+ copy, a revision the hub does not have, a device that is not there).
150
+ """
151
+ torch = _import("torch")
152
+ sentence_transformers = _import("sentence_transformers")
153
+ device = pick_device(settings.gemma_device, torch)
154
+ dtype = pick_dtype(device, torch)
155
+ logger.info(
156
+ "Loading {} at {} text-only on {} ({})",
157
+ settings.gemma_model_id,
158
+ settings.gemma_revision[:12],
159
+ device,
160
+ str(dtype).removeprefix("torch."),
161
+ )
162
+ try:
163
+ model = sentence_transformers.SentenceTransformer(
164
+ settings.gemma_model_id,
165
+ revision=settings.gemma_revision,
166
+ device=device,
167
+ config_kwargs=dict(TEXT_ONLY_CONFIG),
168
+ model_kwargs={"dtype": dtype},
169
+ )
170
+ except (OSError, RuntimeError, ValueError) as exc:
171
+ msg = (
172
+ f"could not load {settings.gemma_model_id} at revision {settings.gemma_revision} "
173
+ f"on {device}: {type(exc).__name__}: {exc}"
174
+ )
175
+ raise EmbeddingProviderUnavailable(msg) from exc
176
+ model.max_seq_length = MAX_TOKENS
177
+ return model
178
+
179
+
180
+ def _finite_rows(encoded: Any, expected: int, dim: int) -> list[list[float] | None]:
181
+ """The encoder's rows as float lists, a non-finite or mis-sized row as ``None``."""
182
+ rows = encoded.tolist() if hasattr(encoded, "tolist") else list(encoded)
183
+ if len(rows) != expected:
184
+ msg = f"the local model returned {len(rows)} vectors for {expected} texts"
185
+ raise EmbeddingResponseInvalid(msg)
186
+ out: list[list[float] | None] = []
187
+ for row in rows:
188
+ vector = [float(x) for x in row]
189
+ out.append(vector if len(vector) == dim and all(map(math.isfinite, vector)) else None)
190
+ return out
191
+
192
+
193
+ class EmbeddingGemmaLocalEmbedder:
194
+ """``EmbeddingProvider`` over EmbeddingGemma 2, run on this machine.
195
+
196
+ The model loads once, lazily, on the first call that needs it, so
197
+ constructing the embedder costs nothing and a run with nothing pending
198
+ never touches torch. ``model_factory`` replaces the loader (tests pass a
199
+ fake; production uses :func:`load_sentence_transformer`).
200
+ """
201
+
202
+ provider = "gemma-local"
203
+
204
+ def __init__(
205
+ self,
206
+ settings: EmbedSettings,
207
+ *,
208
+ model_factory: Callable[[EmbedSettings], Any] | None = None,
209
+ ) -> None:
210
+ self._settings = settings
211
+ self._factory = model_factory or load_sentence_transformer
212
+ self._model: Any = None
213
+ self._lock = threading.Lock()
214
+
215
+ @property
216
+ def model_id(self) -> str:
217
+ """The Hugging Face repository this embedder loads, stamped on every row."""
218
+ return self._settings.gemma_model_id
219
+
220
+ @property
221
+ def dimension(self) -> int:
222
+ """The Matryoshka width every vector is truncated to (768, 512, 256 or 128)."""
223
+ return self._settings.embedding_dim
224
+
225
+ def _loaded(self) -> Any:
226
+ """The model, loaded on first use and shared by every later call."""
227
+ with self._lock:
228
+ if self._model is None:
229
+ self._model = self._factory(self._settings)
230
+ return self._model
231
+
232
+ def _encode(self, model: Any, texts: list[str], *, prompt_name: str) -> Any:
233
+ """One forward pass: prompt, truncate to the width, re-normalize."""
234
+ return model.encode(
235
+ texts,
236
+ prompt_name=prompt_name,
237
+ batch_size=len(texts),
238
+ truncate_dim=self.dimension,
239
+ normalize_embeddings=True,
240
+ convert_to_numpy=True,
241
+ show_progress_bar=False,
242
+ )
243
+
244
+ async def embed_documents(self, texts: list[str]) -> list[list[float] | None]:
245
+ """Embed corpus documents one batch at a time; one slot per input text, in order.
246
+
247
+ Texts are batched shortest first under :func:`plan_batches`, so each
248
+ forward pass pads to a similar length and a long text never pads a
249
+ whole batch to its length, then put back in input order. A batch that
250
+ raises is
251
+ logged and left as ``None`` slots, and so is any single non-finite
252
+ vector; the other batches are kept, and the next run's staleness
253
+ anti-join re-picks the rows that are missing.
254
+ """
255
+ if not texts:
256
+ return []
257
+ model = await asyncio.to_thread(self._loaded)
258
+
259
+ clipped: list[str] = []
260
+ for position, text in enumerate(texts):
261
+ sent, was_truncated = clip_text(text)
262
+ if was_truncated:
263
+ logger.warning(
264
+ "Clipping text at position {} for embedding: {} chars -> {} "
265
+ "(content past the cap will not match a search)",
266
+ position,
267
+ len(text),
268
+ MAX_EMBEDDABLE_CHARS,
269
+ )
270
+ clipped.append(sent)
271
+
272
+ batches = plan_batches([len(t) for t in clipped], max_texts=self._settings.gemma_batch_size)
273
+ vectors: list[list[float] | None] = [None] * len(texts)
274
+ failed_batches = 0
275
+ n_batches = len(batches)
276
+ t0 = time.monotonic()
277
+ for index, positions in enumerate(batches):
278
+ batch = [clipped[i] for i in positions]
279
+ try:
280
+ encoded = await asyncio.to_thread(
281
+ self._encode, model, batch, prompt_name=DOCUMENT_PROMPT
282
+ )
283
+ rows = _finite_rows(encoded, len(batch), self.dimension)
284
+ except (*_BATCH_ERRORS, EmbeddingResponseInvalid) as exc:
285
+ failed_batches += 1
286
+ logger.error(
287
+ "Local batch {} ({} texts) failed ({}: {}); keeping the batches that "
288
+ "succeeded — the next run re-picks these rows",
289
+ index,
290
+ len(batch),
291
+ type(exc).__name__,
292
+ exc,
293
+ )
294
+ continue
295
+ for position, row in zip(positions, rows, strict=True):
296
+ vectors[position] = row
297
+ elapsed = time.monotonic() - t0
298
+
299
+ embedded = sum(1 for v in vectors if v is not None)
300
+ dropped = len(texts) - embedded
301
+ if dropped and not failed_batches:
302
+ logger.error("Dropped {} non-finite vectors; those rows stay unembedded", dropped)
303
+ logger.info(
304
+ "Embedded {} vectors locally across {}/{} batches in {:.2f}s ({:.1f} vec/s)",
305
+ embedded,
306
+ n_batches - failed_batches,
307
+ n_batches,
308
+ elapsed,
309
+ embedded / elapsed if elapsed > 0 else 0.0,
310
+ )
311
+ return vectors
312
+
313
+ def embed_query(self, text: str) -> list[float]:
314
+ """Embed one query under the ``SearchQuery`` prompt, at :attr:`dimension`.
315
+
316
+ Raises
317
+ ------
318
+ EmbeddingProviderUnavailable
319
+ The forward pass failed.
320
+ EmbeddingResponseInvalid
321
+ The vector came back non-finite or at the wrong width.
322
+ """
323
+ sent, was_truncated = clip_text(text)
324
+ if was_truncated:
325
+ logger.warning(
326
+ "Clipping query for embedding: {} chars -> {}", len(text), MAX_EMBEDDABLE_CHARS
327
+ )
328
+ model = self._loaded()
329
+ try:
330
+ encoded = self._encode(model, [sent], prompt_name=QUERY_PROMPT)
331
+ except _BATCH_ERRORS as exc:
332
+ msg = f"the local model failed on the query: {type(exc).__name__}: {exc}"
333
+ raise EmbeddingProviderUnavailable(msg) from exc
334
+ vector = _finite_rows(encoded, 1, self.dimension)[0]
335
+ if vector is None:
336
+ msg = (
337
+ f"the local model returned a non-finite or {self.dimension}-mismatched query vector"
338
+ )
339
+ raise EmbeddingResponseInvalid(msg)
340
+ return vector
341
+
342
+
343
+ __all__ = [
344
+ "BATCH_CHAR_BUDGET",
345
+ "DOCUMENT_PROMPT",
346
+ "INSTALL_HINT",
347
+ "MAX_TOKENS",
348
+ "QUERY_PROMPT",
349
+ "TEXT_ONLY_CONFIG",
350
+ "EmbeddingGemmaLocalEmbedder",
351
+ "load_sentence_transformer",
352
+ "pick_device",
353
+ "pick_dtype",
354
+ "plan_batches",
355
+ ]