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,203 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Embed discovery from the DuckLake, reading only what changed since the last complete pass.
4
+
5
+ The per-session reader (:mod:`atif_embed.infrastructure.corpus_text_rows`)
6
+ parses every session's ``trajectory.json`` on every run, which on a large
7
+ corpus is minutes and gigabytes to find a handful of new rows. The lake
8
+ already holds each step's flattened text and primary uuid in its ``steps``
9
+ table, so this reader asks the lake instead (through
10
+ :class:`~atif_embed.domain.ports.LakeStepsPort`, which atif-cli implements
11
+ over atif-duck).
12
+
13
+ The watermark
14
+ -------------
15
+ ``lake_watermark.json`` sits beside the Lance table, inside the store's own
16
+ directory, so deleting the store deletes it too. It records the lake
17
+ position (lineage and snapshot id) of the last pass that ran to its end and
18
+ whose every yielded row was embedded, the text floor that pass used, and how
19
+ many rows the store held afterwards. The next pass reads only the rows whose
20
+ uuid was inserted or deleted in a lake snapshot after that position.
21
+
22
+ Why that's the same row set a full read gives: a uuid that no step row
23
+ touched since the watermark has the same rows, in the same order, as it had
24
+ then, so a full read would pick the same text for it, and that text was
25
+ embedded when the watermark was written. A uuid a changed row touched is
26
+ re-read in full, every occurrence in corpus order, so the first-wins rule
27
+ lands on the same text a full read would.
28
+
29
+ A full read runs instead whenever the watermark can't be trusted: there is
30
+ none, it names another lineage (the lake was rebuilt), the snapshots after
31
+ it were expired, the text floor changed, or the store's row count isn't the
32
+ one recorded (something outside a complete pass wrote or deleted rows). With
33
+ no usable lake at all, discovery falls back to the per-session reader.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import os
40
+ from typing import TYPE_CHECKING, Any
41
+
42
+ from loguru import logger
43
+
44
+ from atif_embed.domain.discovery import PendingSelection
45
+ from atif_embed.domain.ports import LakePosition
46
+ from atif_embed.infrastructure.corpus_text_rows import MIN_TEXT_CHARS
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import Iterator
50
+ from pathlib import Path
51
+
52
+ from atif_embed.domain.ports import LakeStepSnapshot, LakeStepsPort, TextRowsPort
53
+ from atif_embed.domain.text_stamp import PendingText
54
+
55
+ #: The watermark's filename inside the Lance store directory.
56
+ LAKE_WATERMARK_FILE = "lake_watermark.json"
57
+
58
+ #: Bump when what a watermark means changes (the row key, the order).
59
+ WATERMARK_VERSION = 1
60
+
61
+
62
+ def read_watermark(path: Path) -> dict[str, Any] | None:
63
+ """The recorded watermark, or ``None`` when absent or unreadable."""
64
+ try:
65
+ payload = json.loads(path.read_text(encoding="utf-8"))
66
+ except (OSError, ValueError):
67
+ return None
68
+ return payload if isinstance(payload, dict) else None
69
+
70
+
71
+ def write_watermark(path: Path, payload: dict[str, Any]) -> None:
72
+ """Write the watermark atomically (a temporary file renamed over it)."""
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}")
75
+ tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
76
+ tmp.replace(path)
77
+
78
+
79
+ def watermark_position(payload: dict[str, Any] | None) -> LakePosition | None:
80
+ """The position a watermark records, when it's one this code wrote."""
81
+ if payload is None or payload.get("version") != WATERMARK_VERSION:
82
+ return None
83
+ lineage, snapshot = payload.get("lineage"), payload.get("snapshot_id")
84
+ if not isinstance(lineage, str) or not isinstance(snapshot, int) or isinstance(snapshot, bool):
85
+ return None
86
+ return LakePosition(lineage=lineage, snapshot_id=snapshot)
87
+
88
+
89
+ class LakeTextRows:
90
+ """:class:`~atif_embed.domain.ports.TextRowsPort` over the lake, with a watermark.
91
+
92
+ ``fallback`` serves every call the lake can't (no lake, a stale one, a
93
+ corpus it doesn't hold). :attr:`discovery` says which path the last call
94
+ took: ``corpus`` (the fallback), ``lake-full``, ``lake-incremental`` or
95
+ ``lake-unchanged`` (the watermark is already at the lake's snapshot).
96
+ """
97
+
98
+ def __init__(
99
+ self, *, lake: LakeStepsPort, watermark_path: Path, fallback: TextRowsPort
100
+ ) -> None:
101
+ self._lake = lake
102
+ self._watermark_path = watermark_path
103
+ self._fallback = fallback
104
+ self._completed: LakePosition | None = None
105
+ self._last_source: TextRowsPort | None = None
106
+ self.discovery = "none"
107
+
108
+ def iter_unembedded(
109
+ self,
110
+ corpus_root: Path,
111
+ *,
112
+ embedded: dict[str, str] | None = None,
113
+ limit: int | None = None,
114
+ ) -> Iterator[PendingText]:
115
+ """Yield the texts needing embedding, from the lake when there is one to read."""
116
+ self._completed = None
117
+ snapshot = self._lake.open_steps(corpus_root)
118
+ if snapshot is None:
119
+ self._last_source = self._fallback
120
+ self.discovery = "corpus"
121
+ yield from self._fallback.iter_unembedded(corpus_root, embedded=embedded, limit=limit)
122
+ return
123
+ self._last_source = None
124
+ with snapshot:
125
+ position = snapshot.position
126
+ since = self._trusted_since(snapshot, stored_rows=len(embedded or {}))
127
+ selection = PendingSelection(embedded=embedded, limit=limit)
128
+ if since is not None and since == position:
129
+ self.discovery = "lake-unchanged"
130
+ rows: Iterator[tuple[str, str]] = iter(())
131
+ else:
132
+ self.discovery = "lake-full" if since is None else "lake-incremental"
133
+ rows = snapshot.step_texts(min_chars=MIN_TEXT_CHARS, changed_since=since)
134
+ logger.info(
135
+ "embed discovery: {} at lake snapshot {}{}",
136
+ self.discovery,
137
+ position.snapshot_id,
138
+ f" (changes after snapshot {since.snapshot_id})" if since is not None else "",
139
+ )
140
+ yield from selection.select(rows)
141
+ if selection.exhausted:
142
+ self._completed = position
143
+
144
+ def _trusted_since(
145
+ self, snapshot: LakeStepSnapshot, *, stored_rows: int
146
+ ) -> LakePosition | None:
147
+ """The watermark to read changes after, or ``None`` for a full read (with the reason logged)."""
148
+ payload = read_watermark(self._watermark_path)
149
+ since = watermark_position(payload)
150
+ reason: str | None = None
151
+ if payload is None:
152
+ reason = "no watermark yet"
153
+ elif since is None:
154
+ reason = "the watermark was written by another version"
155
+ elif payload.get("min_text_chars") != MIN_TEXT_CHARS:
156
+ reason = "the text floor changed"
157
+ elif payload.get("stored_rows") != stored_rows:
158
+ reason = (
159
+ f"the store holds {stored_rows} rows, not the {payload.get('stored_rows')} "
160
+ "the watermark recorded"
161
+ )
162
+ elif since.lineage != snapshot.position.lineage:
163
+ reason = "the lake was rebuilt since the watermark"
164
+ elif since.snapshot_id > snapshot.position.snapshot_id:
165
+ reason = "the watermark is ahead of the lake"
166
+ elif since != snapshot.position and not snapshot.can_read_changes_since(since):
167
+ reason = "the lake no longer holds the snapshots after the watermark"
168
+ if reason is not None:
169
+ logger.info("embed discovery: full lake read ({})", reason)
170
+ return None
171
+ return since
172
+
173
+ def commit(self, *, stored_rows: int) -> None:
174
+ """Advance the watermark to the position the last complete pass read."""
175
+ if self._last_source is not None:
176
+ self._last_source.commit(stored_rows=stored_rows)
177
+ return
178
+ if self._completed is None:
179
+ return
180
+ write_watermark(
181
+ self._watermark_path,
182
+ {
183
+ "version": WATERMARK_VERSION,
184
+ "lineage": self._completed.lineage,
185
+ "snapshot_id": self._completed.snapshot_id,
186
+ "min_text_chars": MIN_TEXT_CHARS,
187
+ "stored_rows": stored_rows,
188
+ },
189
+ )
190
+ logger.info(
191
+ "embed discovery: watermark now at lake snapshot {}", self._completed.snapshot_id
192
+ )
193
+ self._completed = None
194
+
195
+
196
+ __all__ = [
197
+ "LAKE_WATERMARK_FILE",
198
+ "WATERMARK_VERSION",
199
+ "LakeTextRows",
200
+ "read_watermark",
201
+ "watermark_position",
202
+ "write_watermark",
203
+ ]
@@ -0,0 +1,440 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Local LanceDB embeddings store.
4
+
5
+ Lance is exactly the right shape for "AI artifact store with versioning +
6
+ native vector search":
7
+
8
+ * writes are append-only fragments via ``tbl.add()``
9
+ * stale rows are removed by predicate via ``tbl.delete()``
10
+ * compaction is explicit via ``tbl.optimize()``
11
+ * the vector index lives next to the data
12
+ * DuckDB reads it back via the lance core extension
13
+ (``INSTALL lance; LOAD lance; ATTACH (TYPE LANCE)`` — that read path lives
14
+ in atif-duck's ``register_vss``; this module never touches DuckDB)
15
+
16
+ Schema evolution policy (v2, 2026-08-27)
17
+ ----------------------------------------
18
+ ADDITIVE columns migrate ONLINE, never via rebuild: opening a store that
19
+ lacks a newer column evolves the table in place (``Table.add_columns``, a
20
+ metadata-only operation — no re-embedding, no data loss) and backfills the
21
+ new column with a sentinel/default. Rows carrying the
22
+ :data:`_PRE_STAMP_SENTINEL` stamp then mismatch their corpus hash in the
23
+ discovery anti-join and re-embed incrementally through the ordinary
24
+ staleness path (delete-before-append), so the store heals itself across
25
+ successive runs while search stays online. A BREAKING change — a provider
26
+ or dimension switch — stays fail-loud via ``embedding_guard``: those
27
+ vectors live in an incompatible space and must be rebuilt deliberately.
28
+
29
+ The store records its schema version in a ``schema_version.json`` sidecar
30
+ next to the table (written on create, checked on open). Bump
31
+ :data:`SCHEMA_VERSION` with the next evolution and key the migration on the
32
+ stored number rather than probing by column name.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from datetime import timedelta
39
+ from pathlib import Path
40
+ from typing import TYPE_CHECKING, Any, Literal
41
+
42
+ import lancedb
43
+ import pyarrow as pa
44
+ from lancedb.index import IvfHnswSq
45
+ from loguru import logger
46
+
47
+ from atif_embed.domain.sql_literal import sql_literal
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Iterable
51
+
52
+ import polars as pl
53
+
54
+ #: Lance table name inside the namespace.
55
+ TABLE_NAME = "embeddings"
56
+
57
+ #: Current store schema version. v1 was the pre-stamp 5-column shape; v2
58
+ #: added ``text_hash`` + ``truncated``. Bump with the next ADDITIVE evolution
59
+ #: and key its migration on the stored number (``read_schema_version``), not
60
+ #: on probing for a column by name.
61
+ SCHEMA_VERSION = 2
62
+
63
+ #: Sidecar filename recording :data:`SCHEMA_VERSION`, written next to the
64
+ #: Lance table directory contents (inside ``lance_uri``). A sidecar rather
65
+ #: than a table column: the version describes the SCHEMA, and reading it must
66
+ #: not require the schema to be readable.
67
+ SCHEMA_VERSION_FILE = "schema_version.json"
68
+
69
+ #: ``text_hash`` stamped on rows that predate the stamp. Angle brackets make
70
+ #: it impossible to equal a real blake2b hex digest, so every pre-stamp row
71
+ #: mismatches its current corpus hash in the discovery anti-join and is
72
+ #: re-embedded incrementally through the ordinary staleness path.
73
+ _PRE_STAMP_SENTINEL = "<pre-stamp>"
74
+
75
+ #: Distance metrics accepted by the LanceDB vector index (``IvfHnswSq``'s
76
+ #: ``distance_type``). Mirrors ``EmbedSettings.hnsw_metric``'s domain.
77
+ DistanceMetric = Literal["cosine", "l2", "dot"]
78
+
79
+ # Process-local connection cache. lancedb.connect() is cheap (no I/O until a
80
+ # table is opened) but caching avoids re-running the consistency-interval setup.
81
+ _DB_CACHE: dict[str, lancedb.DBConnection] = {}
82
+
83
+
84
+ def _has_table(db: lancedb.DBConnection, name: str) -> bool:
85
+ """True iff ``name`` exists in the LanceDB root namespace.
86
+
87
+ ``db.list_tables()`` returns a ``ListTablesResponse`` (pydantic model)
88
+ with a ``tables: list[str]`` field plus an opaque ``page_token``. The
89
+ local namespace is small enough that pagination never kicks in, so we
90
+ read ``.tables`` directly. ``db.table_names()`` is deprecated as of
91
+ lancedb 0.30 (emits ``DeprecationWarning``) — don't reach for it.
92
+ """
93
+ return name in db.list_tables().tables
94
+
95
+
96
+ # pyarrow ships no py.typed and no bundled stubs, so `pa.Schema` is itself an
97
+ # unknown type and any signature naming it reports as partially unknown. The
98
+ # annotation is still the most precise one available and is what a reader needs.
99
+ def lance_schema(dim: int) -> pa.Schema: # pyright: ignore[reportUnknownParameterType]
100
+ """Pyarrow schema for the embeddings table.
101
+
102
+ The ``embedding`` column is a FIXED-SIZE list of float32. Lance treats
103
+ this as a vector column for indexing — a regular ``pa.list_(pa.float32())``
104
+ won't work for ``create_index``.
105
+
106
+ ``text_hash`` is the content stamp the backfill's staleness check reads:
107
+ a re-converted session can change a step's flattened text under the same
108
+ uuid, and without the stamp the uuid-only anti-join would treat that row
109
+ as already embedded forever. ``truncated`` marks rows whose source text
110
+ was clipped before embedding, so a search miss past the clip point is
111
+ attributable instead of invisible.
112
+ """
113
+ return pa.schema(
114
+ [
115
+ pa.field("uuid", pa.string(), nullable=False),
116
+ pa.field("model", pa.string(), nullable=False),
117
+ pa.field("dim", pa.int32(), nullable=False),
118
+ pa.field("embedding", pa.list_(pa.float32(), dim), nullable=False),
119
+ pa.field("embedded_at", pa.timestamp("us", tz="UTC"), nullable=False),
120
+ pa.field("text_hash", pa.string(), nullable=False),
121
+ pa.field("truncated", pa.bool_(), nullable=False),
122
+ ]
123
+ )
124
+
125
+
126
+ def connect_db(uri: Path | str) -> lancedb.DBConnection:
127
+ """Open (or reuse) a LanceDB connection rooted at ``uri``.
128
+
129
+ ``read_consistency_interval=timedelta(0)`` makes every read see writes
130
+ from other processes immediately. Without this, readers get a stale
131
+ manifest snapshot and miss recently appended fragments.
132
+ """
133
+ key = str(Path(uri).resolve())
134
+ cached = _DB_CACHE.get(key)
135
+ if cached is not None:
136
+ return cached
137
+ Path(uri).mkdir(parents=True, exist_ok=True)
138
+ db = lancedb.connect(key, read_consistency_interval=timedelta(0))
139
+ _DB_CACHE[key] = db
140
+ return db
141
+
142
+
143
+ def read_schema_version(lance_uri: Path) -> int | None:
144
+ """Return the store's recorded schema version, or ``None`` when unrecorded.
145
+
146
+ ``None`` covers both a store that predates the sidecar (v1/v2 written
147
+ before 2026-08-27) and a directory with no store at all; callers that
148
+ need to distinguish those already know whether the table exists.
149
+ """
150
+ sidecar = Path(lance_uri) / SCHEMA_VERSION_FILE
151
+ try:
152
+ payload = json.loads(sidecar.read_text(encoding="utf-8"))
153
+ except (OSError, ValueError):
154
+ return None
155
+ version = payload.get("schema_version")
156
+ return int(version) if isinstance(version, int) else None
157
+
158
+
159
+ def write_schema_version(lance_uri: Path, version: int = SCHEMA_VERSION) -> None:
160
+ """Record ``version`` in the store's sidecar (create or overwrite)."""
161
+ sidecar = Path(lance_uri) / SCHEMA_VERSION_FILE
162
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
163
+ sidecar.write_text(json.dumps({"schema_version": version}) + "\n", encoding="utf-8")
164
+
165
+
166
+ def migrate_pre_stamp_table(tbl: Any, lance_uri: Path) -> None:
167
+ """Evolve a pre-stamp table to the v2 schema, in place and online.
168
+
169
+ Adds ``text_hash`` (backfilled with :data:`_PRE_STAMP_SENTINEL`) and
170
+ ``truncated`` (backfilled false) via Lance schema evolution
171
+ (``Table.add_columns`` with SQL default expressions) — a metadata-only
172
+ operation: no rows are dropped, no vectors re-embedded, and readers keep
173
+ working throughout. The sentinel then drives incremental re-embedding
174
+ through the ordinary staleness path (see the module docstring).
175
+ """
176
+ missing = [name for name in ("text_hash", "truncated") if name not in tbl.schema.names]
177
+ additions: dict[str, str] = {}
178
+ if "text_hash" in missing:
179
+ additions["text_hash"] = f"'{_PRE_STAMP_SENTINEL}'"
180
+ if "truncated" in missing:
181
+ additions["truncated"] = "false"
182
+ if not additions:
183
+ return
184
+ tbl.add_columns(additions)
185
+ write_schema_version(lance_uri)
186
+ logger.info(
187
+ "Migrated pre-stamp Lance store at {} to schema v{}: added {} online "
188
+ "(metadata-only; rows re-embed incrementally via the sentinel staleness path)",
189
+ lance_uri,
190
+ SCHEMA_VERSION,
191
+ sorted(additions),
192
+ )
193
+
194
+
195
+ def open_or_create_table(db: lancedb.DBConnection, *, dim: int) -> Any:
196
+ """Open the embeddings table, creating it with the right schema if missing.
197
+
198
+ An existing table that predates a current ADDITIVE column is evolved in
199
+ place (see :func:`migrate_pre_stamp_table`) before it is handed to the
200
+ write path, so ``add_chunk`` never appends a 7-column frame into a
201
+ 5-column table (Lance rejects the schema mismatch).
202
+ """
203
+ if _has_table(db, TABLE_NAME):
204
+ tbl = db.open_table(TABLE_NAME)
205
+ migrate_pre_stamp_table(tbl, Path(db.uri))
206
+ if read_schema_version(Path(db.uri)) is None:
207
+ # A store from before the sidecar existed whose schema is already
208
+ # current: stamp it so the NEXT evolution keys on the number.
209
+ write_schema_version(Path(db.uri))
210
+ return tbl
211
+ tbl = db.create_table(TABLE_NAME, schema=lance_schema(dim), mode="create")
212
+ write_schema_version(Path(db.uri))
213
+ return tbl
214
+
215
+
216
+ def _is_missing_column(exc: Exception, column: str) -> bool:
217
+ """True when ``exc`` is LanceDB's untyped "no such column" ValueError.
218
+
219
+ LanceDB raises a plain ``ValueError`` whose text carries the schema
220
+ complaint, so matching the message is the only available discriminator.
221
+ """
222
+ message = str(exc)
223
+ return "Schema error" in message and f"No field named {column}" in message
224
+
225
+
226
+ def delete_uuids(tbl: Any, uuids: Iterable[str]) -> int:
227
+ """Delete every row whose uuid is in ``uuids``; return how many were named.
228
+
229
+ Re-embedding a changed text must REPLACE the stale vector, not sit
230
+ beside it: two rows under one uuid would make the kNN join fan out and
231
+ rank against pre-change content. Lance has no upsert on this table, so
232
+ delete-then-append is the replace.
233
+ """
234
+ names = sorted(set(uuids))
235
+ if not names:
236
+ return 0
237
+ predicate = f"uuid IN ({', '.join(sql_literal(u) for u in names)})"
238
+ tbl.delete(predicate)
239
+ return len(names)
240
+
241
+
242
+ def add_chunk(tbl: Any, df: pl.DataFrame) -> None:
243
+ """Append one chunk of embeddings.
244
+
245
+ The polars DataFrame must have ``embedding: pl.Array(pl.Float32, dim)``
246
+ — the fixed-size list shape Lance expects. ``to_arrow()`` preserves the
247
+ distinction; a regular ``pl.List`` becomes a variable-size list and Lance
248
+ will reject it as a vector column for indexing.
249
+ """
250
+ arrow_table = df.to_arrow()
251
+ tbl.add(arrow_table)
252
+
253
+
254
+ def ensure_index(tbl: Any, *, metric: DistanceMetric = "cosine") -> None:
255
+ """Create the IVF_HNSW_SQ vector index on the ``embedding`` column.
256
+
257
+ No-op if an index already exists. Index name is exactly ``IVF_HNSW_SQ``
258
+ (scalar-quantized HNSW) — there is no plain ``IVF_HNSW`` literal in
259
+ LanceDB. SQ gives a good size/recall balance at small-corpus scale.
260
+ """
261
+ try:
262
+ existing = tbl.list_indices()
263
+ except (AttributeError, RuntimeError):
264
+ existing = []
265
+ for idx in existing:
266
+ if getattr(idx, "column", None) == "embedding":
267
+ return
268
+ if "embedding" in getattr(idx, "columns", []):
269
+ return
270
+ try:
271
+ # lancedb >=0.30 unified index API: pass the vector column as the first
272
+ # positional arg and an index-config object via ``config=``. The legacy
273
+ # ``metric=/vector_column_name=/index_type=`` kwargs are deprecated
274
+ # (they emit a DeprecationWarning as of 0.34). ``IvfHnswSq`` is the
275
+ # config class for the scalar-quantized HNSW index this store uses;
276
+ # ``distance_type`` carries the former ``metric``.
277
+ tbl.create_index("embedding", config=IvfHnswSq(distance_type=metric))
278
+ except (RuntimeError, ValueError) as exc:
279
+ # Index creation can fail on tiny tables (no vectors yet, or k-means
280
+ # seeding hits a degenerate split). Log and continue — cosine search
281
+ # still works without an index, just slower (brute-force scan).
282
+ logger.warning("LanceDB create_index failed ({}); falling back to brute-force scan", exc)
283
+
284
+
285
+ def optimize_if_needed(tbl: Any) -> None:
286
+ """Compact accumulated fragments + clean up old versions.
287
+
288
+ Lance accumulates one fragment per ``tbl.add()`` call. Past ~100
289
+ fragments read latency starts to degrade; the backfill triggers
290
+ compaction much earlier (every 8 chunks) because the embeddings table is
291
+ small. ``tbl.optimize()`` returns ``None`` and mutates in place.
292
+ """
293
+ try:
294
+ tbl.optimize()
295
+ except (RuntimeError, AttributeError) as exc:
296
+ logger.warning("Lance optimize failed: {}", exc)
297
+
298
+
299
+ def count_rows(lance_uri: Path) -> int:
300
+ """Return the row count of the embeddings Lance table, or 0 when missing."""
301
+ if not lance_uri.exists():
302
+ return 0
303
+ db = connect_db(lance_uri)
304
+ if not _has_table(db, TABLE_NAME):
305
+ return 0
306
+ return int(db.open_table(TABLE_NAME).count_rows())
307
+
308
+
309
+ def table_identity(lance_uri: Path) -> tuple[str, int] | None:
310
+ """Return the store's stamped ``(model, dim)`` from its first row, or ``None``.
311
+
312
+ ``None`` means the store is empty or absent (fresh install), so any
313
+ provider may claim it. Every row stamps the same ``model`` / ``dim`` (see
314
+ ``run_backfill``), so reading the first row is sufficient. Used by the
315
+ fail-loud provider/dimension guard
316
+ (:func:`atif_embed.domain.embedding_guard.ensure_store_matches`).
317
+ """
318
+ n = count_rows(lance_uri)
319
+ if n == 0:
320
+ return None
321
+ db = connect_db(lance_uri)
322
+ tbl = db.open_table(TABLE_NAME)
323
+ arrow = tbl.search().select(["model", "dim"]).limit(1).to_arrow()
324
+ if arrow.num_rows == 0:
325
+ return None
326
+ model = str(arrow.column("model").to_pylist()[0])
327
+ dim = int(arrow.column("dim").to_pylist()[0])
328
+ return (model, dim)
329
+
330
+
331
+ def get_embedded_hashes(lance_uri: Path) -> dict[str, str]:
332
+ """Return ``{uuid: text_hash}`` for every embedded row.
333
+
334
+ Used by the backfill's anti-join: a uuid whose stored hash differs from
335
+ the corpus's current text is STALE, not embedded, so it must be re-picked
336
+ and its old row deleted. Reads only two string columns via a
337
+ column-projected scan so the ``FLOAT[dim]`` vector column is never
338
+ decoded — ``to_arrow()`` has no projection pushdown and materializes the
339
+ full N×dim float matrix before a ``.select()`` could prune it (~221 MB
340
+ peak at 20k×1024 unprojected vs ~0 MB projected). The explicit
341
+ ``limit(count_rows())`` overrides LanceDB's default 10-row query cap so
342
+ the scan returns every row.
343
+
344
+ A store written before ``text_hash`` existed has no such column, and
345
+ LanceDB reports that as a bare ``ValueError``. That is NOT terminal:
346
+ every row gets :data:`_PRE_STAMP_SENTINEL` instead (a value no real
347
+ blake2b digest can equal), so the discovery anti-join marks every row
348
+ stale and the store migrates itself incrementally through the ordinary
349
+ delete-before-append path — no rebuild, no empty-store window. The
350
+ 2026-08-24 incident (both production corpora rebuilt, ~3.4M vectors of Cohere
351
+ spend) came from raising a destroy-and-rebuild error here instead.
352
+ """
353
+ n = count_rows(lance_uri)
354
+ if n == 0:
355
+ return {}
356
+ db = connect_db(lance_uri)
357
+ tbl = db.open_table(TABLE_NAME)
358
+ try:
359
+ arrow = tbl.search().select(["uuid", "text_hash"]).limit(n).to_arrow()
360
+ except ValueError as exc:
361
+ if not _is_missing_column(exc, "text_hash"):
362
+ raise
363
+ logger.info(
364
+ "Embedding store at {} predates the text_hash stamp; treating all "
365
+ "{} rows as stale for incremental online re-embedding",
366
+ lance_uri,
367
+ n,
368
+ )
369
+ arrow = tbl.search().select(["uuid"]).limit(n).to_arrow()
370
+ return {str(u): _PRE_STAMP_SENTINEL for u in arrow.column("uuid").to_pylist()}
371
+ uuids = arrow.column("uuid").to_pylist()
372
+ hashes = arrow.column("text_hash").to_pylist()
373
+ return {str(u): str(h) for u, h in zip(uuids, hashes, strict=True)}
374
+
375
+
376
+ class LanceVectorStore:
377
+ """:class:`~atif_embed.domain.ports.VectorStorePort` over this module.
378
+
379
+ Thin object adapter binding the module functions to one ``lance_uri`` +
380
+ ``dim`` pair so the use case takes a single injectable seam.
381
+ """
382
+
383
+ def __init__(self, lance_uri: Path, *, dim: int) -> None:
384
+ self._lance_uri = lance_uri
385
+ self._dim = dim
386
+ self._tbl: Any | None = None
387
+
388
+ def _table(self) -> Any:
389
+ if self._tbl is None:
390
+ db = connect_db(self._lance_uri)
391
+ self._tbl = open_or_create_table(db, dim=self._dim)
392
+ return self._tbl
393
+
394
+ def table_identity(self) -> tuple[str, int] | None:
395
+ """The store's stamped ``(model, dim)``, or None when it has none."""
396
+ return table_identity(self._lance_uri)
397
+
398
+ def get_embedded_hashes(self) -> dict[str, str]:
399
+ """``{uuid: text_hash}`` for every row already in the store."""
400
+ return get_embedded_hashes(self._lance_uri)
401
+
402
+ def delete_uuids(self, uuids: Iterable[str]) -> int:
403
+ """Delete the rows carrying ``uuids``; return how many went."""
404
+ return delete_uuids(self._table(), uuids)
405
+
406
+ def add_chunk(self, df: pl.DataFrame) -> None:
407
+ """Append one embedded chunk to the table."""
408
+ add_chunk(self._table(), df)
409
+
410
+ def optimize(self) -> None:
411
+ """Compact the table's fragments when it has accumulated enough."""
412
+ optimize_if_needed(self._table())
413
+
414
+ def ensure_index(self, *, metric: str = "cosine") -> None:
415
+ """Create the vector index under ``metric`` when the table lacks one."""
416
+ if metric not in {"cosine", "l2", "dot"}:
417
+ msg = f"Unsupported Lance metric: {metric!r}"
418
+ raise ValueError(msg)
419
+ ensure_index(self._table(), metric=metric) # type: ignore[arg-type]
420
+
421
+
422
+ __all__ = [
423
+ "SCHEMA_VERSION",
424
+ "SCHEMA_VERSION_FILE",
425
+ "TABLE_NAME",
426
+ "LanceVectorStore",
427
+ "add_chunk",
428
+ "connect_db",
429
+ "count_rows",
430
+ "delete_uuids",
431
+ "ensure_index",
432
+ "get_embedded_hashes",
433
+ "lance_schema",
434
+ "migrate_pre_stamp_table",
435
+ "open_or_create_table",
436
+ "optimize_if_needed",
437
+ "read_schema_version",
438
+ "table_identity",
439
+ "write_schema_version",
440
+ ]
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Build the embedder ``ATIF_SQL_EMBED_PROVIDER`` selects.
4
+
5
+ The one place that names a concrete :class:`~atif_embed.domain.ports.EmbeddingProvider`.
6
+ Each adapter is imported only when selected, so the Cohere path never loads
7
+ torch and the Gemma path never loads boto3.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from atif_embed.domain.ports import EmbeddingProvider
16
+ from atif_embed.infrastructure.settings import EmbedSettings
17
+
18
+
19
+ def build_embedder(settings: EmbedSettings) -> EmbeddingProvider:
20
+ """The selected provider's embedder, constructed without loading a model or a client."""
21
+ if settings.embed_provider == "gemma":
22
+ from atif_embed.infrastructure.gemma_local import EmbeddingGemmaLocalEmbedder
23
+
24
+ return EmbeddingGemmaLocalEmbedder(settings)
25
+ from atif_embed.infrastructure.cohere_bedrock import CohereBedrockEmbedder
26
+
27
+ return CohereBedrockEmbedder(settings)
28
+
29
+
30
+ __all__ = ["build_embedder"]