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,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,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,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"]
|