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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|