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,94 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Domain error taxonomy for atif-embed.
4
+
5
+ Every failure that can reach the CLI must be a :class:`DomainError`: the
6
+ ``embed`` command catches only that base class and turns it into a
7
+ classified ``{kind, exit_code, hint}`` envelope. Anything escaping the
8
+ taxonomy surfaces as a raw traceback instead.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ class DomainError(Exception):
15
+ """Base class for atif-embed domain errors.
16
+
17
+ ``terminal`` distinguishes conditions no retry can clear (the store or
18
+ its config needs an operator) from transient runs the next tick may
19
+ succeed at. The CLI maps terminal errors to their own exit code so an
20
+ unattended lane can stop retrying instead of burning identical ticks —
21
+ the 2026-08-24 schema-stale condition retried 400+ times over 3 days
22
+ with zero escalation because every failure exited alike.
23
+ """
24
+
25
+ #: True when retrying without operator action cannot succeed.
26
+ terminal: bool = False
27
+
28
+
29
+ class EmbeddingProviderMismatch(DomainError): # noqa: N818 — names a store state, not an "*Error"
30
+ """Raised on a stamped-vs-active ``(model, dim)`` mismatch.
31
+
32
+ The Lance store records the identity of the embedder that wrote it, and it
33
+ differs from the identity of the embedder now asking to read or extend it.
34
+
35
+ Different embedding models produce vectors in incompatible spaces, so
36
+ appending or querying across a provider switch yields numerically valid
37
+ but semantically garbage cosine scores. This error is terminal: the store
38
+ must be dropped and re-embedded under the new provider.
39
+ """
40
+
41
+ terminal = True
42
+
43
+
44
+ class EmbeddingStoreSchemaStale(DomainError): # noqa: N818 — names a store state, not an "*Error"
45
+ """A Lance store's schema requires operator action before embeds can run.
46
+
47
+ ADDITIVE drift no longer raises this: a store missing ``text_hash``
48
+ migrates online via schema evolution + the sentinel staleness path (see
49
+ ``infrastructure.lance_store``). The class stays in the taxonomy for
50
+ schema states that genuinely cannot self-heal, and as the terminal
51
+ exemplar the refresh lane's retry suppression keys on.
52
+ """
53
+
54
+ terminal = True
55
+
56
+
57
+ class EmbeddingProviderUnavailable(DomainError): # noqa: N818 — names a provider state, not an "*Error"
58
+ """The embedding backend could not be reached or refused the request.
59
+
60
+ Covers what escapes tenacity's retry budget plus the non-retryable
61
+ botocore failures (expired or denied credentials, a rejected request
62
+ shape). Terminal for this run; the next run's staleness anti-join
63
+ re-picks whatever never got written.
64
+ """
65
+
66
+
67
+ class EmbeddingProviderNotInstalled(DomainError): # noqa: N818 — names an install state, not an "*Error"
68
+ """The selected provider's libraries are not installed.
69
+
70
+ EmbeddingGemma 2 runs through the optional ``local`` extra (torch,
71
+ transformers, sentence-transformers). Selecting it without the extra
72
+ cannot succeed on a retry: an operator installs the extra or selects
73
+ another provider, so the error is terminal.
74
+ """
75
+
76
+ terminal = True
77
+
78
+
79
+ class EmbeddingResponseInvalid(DomainError): # noqa: N818 — names a response state, not an "*Error"
80
+ """The backend answered, but not in the shape the adapter can read.
81
+
82
+ Distinct from :class:`EmbeddingProviderUnavailable` because it points at
83
+ a wire-format change rather than at credentials or capacity.
84
+ """
85
+
86
+
87
+ __all__ = [
88
+ "DomainError",
89
+ "EmbeddingProviderMismatch",
90
+ "EmbeddingProviderNotInstalled",
91
+ "EmbeddingProviderUnavailable",
92
+ "EmbeddingResponseInvalid",
93
+ "EmbeddingStoreSchemaStale",
94
+ ]
@@ -0,0 +1,223 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Ports for atif-embed: what the embed use case NEEDS from the world.
4
+
5
+ Four seams:
6
+
7
+ * :class:`EmbeddingProvider` — async document embedding (batched,
8
+ float-widened) + sync query embedding, stamped with ``model_id`` /
9
+ ``dimension``.
10
+ * :class:`VectorStorePort` — the Lance-backed store surface.
11
+ * :class:`TextRowsPort` — the corpus seam. atif-embed may never import
12
+ atif-duck (import-linter independence contract), so the embeddable text
13
+ rows come through this port; the duckdb implementation in atif-embed's OWN
14
+ infrastructure reads the CONTRACT corpus layout directly (the ConverterPort
15
+ precedent from atif-corpus).
16
+ * :class:`LakeStepsPort` — the corpus's steps as the DuckLake holds them.
17
+ atif-duck owns the lake, so atif-cli implements this port over it and
18
+ hands it to :class:`~atif_embed.infrastructure.lake_text_rows.LakeTextRows`,
19
+ which reads only what changed since the last complete pass.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING, Protocol, Self
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Iterable, Iterator
29
+ from pathlib import Path
30
+ from types import TracebackType
31
+
32
+ import polars as pl
33
+
34
+ from atif_embed.domain.text_stamp import PendingText
35
+
36
+
37
+ class EmbeddingProvider(Protocol):
38
+ """Anything that can embed documents and queries into one vector space."""
39
+
40
+ @property
41
+ def model_id(self) -> str:
42
+ """Globally unique model identity stamped on every stored row."""
43
+ ...
44
+
45
+ @property
46
+ def dimension(self) -> int:
47
+ """Fixed output vector width."""
48
+ ...
49
+
50
+ async def embed_documents(self, texts: list[str]) -> list[list[float] | None]:
51
+ """Embed corpus documents; one slot per input text, in the same order.
52
+
53
+ A slot is ``None`` when that text could not be embedded. Returning
54
+ per-text outcomes rather than raising is what makes loss bounded: a
55
+ batch that fails terminally must not discard the sibling batches
56
+ whose embeddings were already billed.
57
+ """
58
+ ...
59
+
60
+ def embed_query(self, text: str) -> list[float]:
61
+ """Embed one query string as a float vector of length :attr:`dimension`."""
62
+ ...
63
+
64
+
65
+ class VectorStorePort(Protocol):
66
+ """The embeddings store surface the backfill writes through."""
67
+
68
+ def table_identity(self) -> tuple[str, int] | None:
69
+ """Return the store's stamped ``(model, dim)``, or ``None`` when empty."""
70
+ ...
71
+
72
+ def get_embedded_hashes(self) -> dict[str, str]:
73
+ """Return ``{uuid: text_hash}`` for every row currently embedded."""
74
+ ...
75
+
76
+ def delete_uuids(self, uuids: Iterable[str]) -> int:
77
+ """Remove the rows for ``uuids``; return how many uuids were named."""
78
+ ...
79
+
80
+ def add_chunk(self, df: pl.DataFrame) -> None:
81
+ """Append one chunk of embedding rows."""
82
+ ...
83
+
84
+ def optimize(self) -> None:
85
+ """Compact accumulated fragments (best-effort)."""
86
+ ...
87
+
88
+ def ensure_index(self, *, metric: str = "cosine") -> None:
89
+ """Create the vector index if missing (best-effort)."""
90
+ ...
91
+
92
+
93
+ class TextRowsPort(Protocol):
94
+ """Anything that can yield the corpus's embeddable text rows.
95
+
96
+ Contract semantics (CONTRACT-V2 §VSS): the embeddable unit is a step's
97
+ flattened text — main chain AND sidechain — of at least 32 characters,
98
+ keyed by the step's PRIMARY uuid (the first ``extra.source_uuids`` entry;
99
+ steps without source uuids are skipped, they cannot join back to the
100
+ ``messages``/edges surface).
101
+ """
102
+
103
+ def iter_unembedded(
104
+ self,
105
+ corpus_root: Path,
106
+ *,
107
+ embedded: dict[str, str] | None = None,
108
+ limit: int | None = None,
109
+ ) -> Iterator[PendingText]:
110
+ """Yield the texts needing embedding, lazily.
111
+
112
+ ``embedded`` is the store's ``{uuid: text_hash}`` map. A uuid absent
113
+ from it is NEW; a uuid present under a different hash is STALE (its
114
+ text changed since it was embedded) and is yielded with
115
+ ``replaces_existing=True``.
116
+
117
+ Yielding lazily bounds what the CALLER holds to one write-chunk. It
118
+ says nothing about what an implementation holds internally: whether
119
+ peak resident text is a fraction of the corpus is a property each
120
+ adapter must establish for itself.
121
+ """
122
+ ...
123
+
124
+ @property
125
+ def discovery(self) -> str:
126
+ """Which path the last :meth:`iter_unembedded` read (``corpus``, ``lake-full``, ...)."""
127
+ ...
128
+
129
+ def commit(self, *, stored_rows: int) -> None:
130
+ """Record that every row the last :meth:`iter_unembedded` yielded is now stored.
131
+
132
+ The use case calls it after a run that consumed the whole discovery
133
+ and embedded every row of it, with the store's row count afterwards.
134
+ An adapter with a watermark advances it here (and only when that
135
+ discovery ran to its end, not to a ``--limit``); one without does
136
+ nothing.
137
+ """
138
+ ...
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class LakePosition:
143
+ """Where one corpus's lake history stands: which history, and how far along it.
144
+
145
+ ``lineage`` changes whenever the corpus's rows are loaded from scratch (a
146
+ ``lake rebuild``, or the first load of a corpus), because snapshot ids
147
+ from one history mean nothing in the next.
148
+ """
149
+
150
+ lineage: str
151
+ snapshot_id: int
152
+
153
+
154
+ class LakeStepSnapshot(Protocol):
155
+ """One corpus's steps at one lake snapshot, open until closed."""
156
+
157
+ @property
158
+ def position(self) -> LakePosition:
159
+ """The snapshot being read."""
160
+ ...
161
+
162
+ def can_read_changes_since(self, since: LakePosition) -> bool:
163
+ """True when the lake still holds every snapshot after ``since`` in this lineage."""
164
+ ...
165
+
166
+ def step_texts(
167
+ self, *, min_chars: int, changed_since: LakePosition | None
168
+ ) -> Iterator[tuple[str, str]]:
169
+ """``(primary uuid, flattened text)`` for every qualifying step, in corpus order.
170
+
171
+ Corpus order is session id, then step id. A qualifying step has a
172
+ primary uuid, a non-NULL text and at least ``min_chars`` characters
173
+ of it. With ``changed_since``, only rows whose uuid appears in a step
174
+ row inserted or deleted after that position are read; those rows
175
+ still come back in corpus order, every occurrence of each uuid, so
176
+ the first-wins rule picks the same text a full read would.
177
+ """
178
+ ...
179
+
180
+ def step_keys(self) -> frozenset[str]:
181
+ """Every step's primary uuid in the corpus, whatever its text."""
182
+ ...
183
+
184
+ def close(self) -> None:
185
+ """Release the connection."""
186
+ ...
187
+
188
+ def __enter__(self) -> Self:
189
+ """Open for a ``with`` block."""
190
+ ...
191
+
192
+ def __exit__(
193
+ self,
194
+ exc_type: type[BaseException] | None,
195
+ exc: BaseException | None,
196
+ tb: TracebackType | None,
197
+ ) -> None:
198
+ """Close at the end of the ``with`` block."""
199
+ ...
200
+
201
+
202
+ class LakeStepsPort(Protocol):
203
+ """Open the lake's view of one corpus's steps."""
204
+
205
+ def open_steps(self, corpus_root: Path) -> LakeStepSnapshot | None:
206
+ """The corpus's steps at the lake's current snapshot, or ``None`` to fall back.
207
+
208
+ ``None`` means no lake the caller should read: none exists, its
209
+ schema is stale, it doesn't hold this corpus, or it holds another
210
+ directory under the same name. The caller then reads the per-session
211
+ artifacts, as it did before the lake existed.
212
+ """
213
+ ...
214
+
215
+
216
+ __all__ = [
217
+ "EmbeddingProvider",
218
+ "LakePosition",
219
+ "LakeStepSnapshot",
220
+ "LakeStepsPort",
221
+ "TextRowsPort",
222
+ "VectorStorePort",
223
+ ]
@@ -0,0 +1,49 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Single-quoted SQL literal escaping for the one predicate that needs it.
4
+
5
+ LanceDB's delete predicate takes a string, not parameters, so ``lance_store``
6
+ escapes the uuids it deletes with this. The DuckDB reader in
7
+ ``corpus_text_rows`` no longer needs it: its trajectory paths reach
8
+ ``read_json`` as a bound list parameter. Escaping is a correctness requirement
9
+ of the predicate, and it lives in ``domain`` so a second caller would share
10
+ ONE implementation instead of carrying a copy.
11
+
12
+ :data:`SqlFragment` marks text that may be spliced into a statement; a value
13
+ from outside the process is a plain ``str`` until it passes through here.
14
+
15
+ atif-embed may never import atif-duck (the import-linter independence
16
+ contract), so this module is deliberately atif-embed's own rather than a reuse
17
+ of atif-duck's twin.
18
+
19
+ Pure module: stdlib only, no duckdb, no lancedb.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import NewType
25
+
26
+ #: Text that may be spliced into a SQL statement or predicate.
27
+ SqlFragment = NewType("SqlFragment", str)
28
+
29
+
30
+ def sql_literal(value: str) -> SqlFragment:
31
+ """Escape ``value`` as a single-quoted SQL string literal.
32
+
33
+ Doubles every embedded single quote, per the SQL standard's own escape.
34
+
35
+ Parameters
36
+ ----------
37
+ value
38
+ Value to embed in a statement or predicate.
39
+
40
+ Returns
41
+ -------
42
+ SqlFragment
43
+ ``value`` wrapped in single quotes, embedded quotes doubled.
44
+ """
45
+ escaped = value.replace("'", "''")
46
+ return SqlFragment(f"'{escaped}'")
47
+
48
+
49
+ __all__ = ["SqlFragment", "sql_literal"]
@@ -0,0 +1,71 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Per-text invariants the store must record: the content stamp and the cap.
4
+
5
+ Two rules, both about not losing information silently.
6
+
7
+ **The stamp.** A step's uuid is stable across re-conversions but its flattened
8
+ text is not: a harbor or converter fix changes the text under the same uuid.
9
+ Keying the store on uuid alone therefore makes the first vector permanent —
10
+ the anti-join sees the uuid, calls it embedded, and semantic search keeps
11
+ ranking against pre-fix text with nothing in the store to reveal it. Every
12
+ row also stamps :func:`text_hash` of the exact text it was built from, so a
13
+ mismatch means STALE and the row is replaced.
14
+
15
+ **The cap.** Texts over :data:`MAX_EMBEDDABLE_CHARS` are sent head-only, so
16
+ their vector cannot match content past the cap. Every row records whether it
17
+ was capped, which turns an otherwise unexplainable search miss into an
18
+ attributable one.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from hashlib import blake2b
25
+
26
+ #: 16 bytes (128 bit) of blake2b, hex-encoded. Collision risk is negligible
27
+ #: at corpus scale and the digest is a third the width of sha256 in a column
28
+ #: read in full on every discovery pass.
29
+ _DIGEST_BYTES = 16
30
+
31
+ #: Characters of a text that reach the embedder. Bedrock's body ceiling is
32
+ #: 20 MB and a full batch is 96 texts, so 50K each leaves headroom. Both the
33
+ #: row's ``truncated`` stamp and the adapter's wire-level clip read this one
34
+ #: constant, so the flag can never disagree with what was actually sent.
35
+ MAX_EMBEDDABLE_CHARS = 50_000
36
+
37
+
38
+ def text_hash(text: str) -> str:
39
+ """Return the stable content stamp for one embeddable text."""
40
+ return blake2b(text.encode("utf-8"), digest_size=_DIGEST_BYTES).hexdigest()
41
+
42
+
43
+ def clip_text(text: str) -> tuple[str, bool]:
44
+ """Return ``(text_to_send, was_truncated)`` for one embeddable text."""
45
+ if len(text) <= MAX_EMBEDDABLE_CHARS:
46
+ return text, False
47
+ return text[:MAX_EMBEDDABLE_CHARS], True
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class PendingText:
52
+ """One text the backfill must embed, with its staleness verdict.
53
+
54
+ ``replaces_existing`` is True when the store already holds a row for
55
+ this uuid under a DIFFERENT hash: that row must be deleted before the
56
+ new vector is appended, or the uuid would fan out to two vectors and the
57
+ kNN join would rank against both.
58
+ """
59
+
60
+ uuid: str
61
+ text: str
62
+ text_hash: str
63
+ replaces_existing: bool
64
+
65
+ @property
66
+ def truncated(self) -> bool:
67
+ """True when this text exceeds the cap and embeds head-only."""
68
+ return len(self.text) > MAX_EMBEDDABLE_CHARS
69
+
70
+
71
+ __all__ = ["MAX_EMBEDDABLE_CHARS", "PendingText", "clip_text", "text_hash"]
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Infrastructure adapters: Bedrock embedder, Lance store, corpus text reader."""