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,641 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ r"""Write and detect the typed columnar artifacts (see :mod:`atif_duck.domain.columnar`).
4
+
5
+ Two jobs, both keyed on the contract in the domain module:
6
+
7
+ * :class:`ColumnarArtifactProducer` writes the five parquet files for one
8
+ session from its trajectory dict. The lake loader runs it to stage each
9
+ session it loads (:mod:`atif_duck.infrastructure.lake`); it still fits
10
+ atif-corpus's ``ArtifactProducer`` port, though materialize no longer
11
+ injects it. A corpus written before that keeps these files until
12
+ ``atif-sql corpus slim`` removes them.
13
+ * :func:`session_has_columnar` and :func:`columnar_coverage` answer "may this
14
+ session be read from parquet?" for the registry and for ``atif-sql status``,
15
+ with one predicate so the two can never disagree.
16
+
17
+ Why the producer walks the dict instead of ``read_json`` over the stored
18
+ trajectory: DuckDB's JSON reader holds the whole document plus its
19
+ typed conversion in memory, which measured at 515 MB just to parse a 204 MB
20
+ session, against a materialize pass that otherwise peaks at 667 MB on the same
21
+ corpus. The dict is already in memory, so the producer streams it into DuckDB
22
+ as byte-bounded Arrow batches (one JSON text per step member, per tool call,
23
+ per result) and lets DuckDB do the typing.
24
+
25
+ Why one DuckDB statement per batch and a pyarrow writer, rather than one
26
+ ``COPY ... TO`` per file: everything DuckDB allocates outside its buffer
27
+ manager (yyjson documents, string heaps, exported Arrow buffers) stays
28
+ resident in its bundled allocator until the statement ends, so a single COPY
29
+ over the 204 MB session measured +416 MB over the dict, and exporting the
30
+ typed rows as Arrow tables measured +230 MB, both roughly the size of the
31
+ text they had processed. Fetching each batch's typed rows as Python objects
32
+ (glibc-allocated, freed on the spot) and appending them to the parquet file
33
+ as one row group each measured +108 MB with 1 MiB batches. Row order is the
34
+ trajectory's array order by construction.
35
+
36
+ Why DuckDB still does the typing: the JSON columns (``tool_input``,
37
+ ``content``, ``source_uuids``) must be byte-identical to what the JSON-path
38
+ views return, and those are yyjson's re-serialization of the value. Python's
39
+ ``json.dumps`` output differs (``\u`` escapes, number spelling), but DuckDB
40
+ re-parses whatever text it is handed and re-serializes it, so passing
41
+ ``json.dumps(value)`` in and reading a JSON column out lands on exactly the
42
+ bytes ``json_extract`` would have produced from ``trajectory.json``. The
43
+ column expressions themselves are shared with the views through
44
+ :mod:`atif_duck.infrastructure.projections`. TIMESTAMP columns cross into
45
+ Python as ``epoch_us`` integers and are cast back on the Arrow side, so the
46
+ value never passes through ``datetime`` and its range.
47
+
48
+ File mode: every parquet is written ``0o444``. The query sandbox grants each
49
+ one by path so a view can read it lazily, and a DuckDB path grant also permits
50
+ ``COPY ... TO <path> (USE_TMP_FILE false)``. A read-only file turns that write
51
+ into an ``IO Error`` for any non-root user, which is what keeps injected SQL
52
+ from rewriting the transcript-derived surface it is reading.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import os
59
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
60
+ from dataclasses import dataclass
61
+ from typing import TYPE_CHECKING, Any
62
+
63
+ from loguru import logger
64
+
65
+ from atif_duck.domain.columnar import (
66
+ COLUMNAR_FILENAMES,
67
+ COLUMNAR_SCHEMA_VERSION,
68
+ COLUMNAR_SCHEMAS,
69
+ META_COLUMNAR_KEY,
70
+ MIN_PARQUET_BYTES,
71
+ SESSION_COLUMNS,
72
+ SESSION_EVENTS_JSONL,
73
+ SESSION_EVENTS_PARQUET,
74
+ SESSION_PARQUET,
75
+ STEPS_PARQUET,
76
+ TOOL_CALLS_PARQUET,
77
+ TOOL_RESULTS_PARQUET,
78
+ columnar_paths,
79
+ is_current_columnar_schema,
80
+ )
81
+ from atif_duck.domain.session_id import session_id_rejection
82
+ from atif_duck.domain.sql_literal import SqlFragment, sql_literal
83
+ from atif_duck.infrastructure.projections import (
84
+ CALL_COLUMNS,
85
+ EVENT_COLUMNS,
86
+ MEMBER_COLUMNS,
87
+ RESULT_COLUMNS,
88
+ STEP_MEMBERS,
89
+ render,
90
+ step_columns,
91
+ step_key_columns,
92
+ )
93
+ from atif_duck.infrastructure.stored_artifacts import iter_lines
94
+
95
+ if TYPE_CHECKING:
96
+ from pathlib import Path
97
+
98
+ import duckdb
99
+
100
+ # pyarrow ships no type information, so its schema and batch objects are
101
+ # annotated ``Any`` here (the same treatment atif-analytics gives them); the
102
+ # checker would otherwise report every one of them as Unknown.
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Detection (shared by the registry and `atif-sql status`)
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ def _readable_parquet(path: Path) -> bool:
110
+ try:
111
+ return path.stat().st_size > MIN_PARQUET_BYTES
112
+ except OSError:
113
+ return False
114
+
115
+
116
+ def session_has_columnar(session_dir: Path, meta_columnar_schema: object) -> bool:
117
+ """True when ``session_dir`` may be read from its columnar artifacts.
118
+
119
+ Two conditions, both required: ``meta.json``'s ``columnar_schema`` names
120
+ this build's schema version, and all five parquet files are present and
121
+ longer than a parquet header. A session failing either is read from
122
+ ``trajectory.json``, which is always correct and merely slower, so this
123
+ predicate can only ever cost speed.
124
+ """
125
+ return is_current_columnar_schema(meta_columnar_schema) and all(
126
+ _readable_parquet(path) for path in columnar_paths(session_dir)
127
+ )
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class ColumnarCoverage:
132
+ """How many complete sessions a corpus can serve from parquet versus JSON."""
133
+
134
+ #: Sessions the registry reads from their columnar artifacts.
135
+ columnar_sessions: int
136
+ #: Sessions the registry reads from ``trajectory.json``.
137
+ json_sessions: int
138
+
139
+ @property
140
+ def query_path(self) -> str:
141
+ """``columnar``, ``json``, ``mixed``, or ``empty`` (no complete session)."""
142
+ if self.columnar_sessions and not self.json_sessions:
143
+ return "columnar"
144
+ if self.json_sessions and not self.columnar_sessions:
145
+ return "json"
146
+ if self.columnar_sessions and self.json_sessions:
147
+ return "mixed"
148
+ return "empty"
149
+
150
+ @property
151
+ def total_sessions(self) -> int:
152
+ """Complete sessions of either kind."""
153
+ return self.columnar_sessions + self.json_sessions
154
+
155
+
156
+ def columnar_coverage(corpus_root: Path) -> ColumnarCoverage:
157
+ """Count columnar-readable and JSON-only sessions under ``corpus_root``.
158
+
159
+ Reads each session's ``meta.json`` (a torn dir without one is not a
160
+ session at all, exactly as the registry's meta gate sees it) and applies
161
+ :func:`session_has_columnar`. Used by ``atif-sql status`` so an operator
162
+ can see which path ``query`` will take without opening DuckDB.
163
+ """
164
+ sessions_dir = corpus_root / "sessions"
165
+ if not sessions_dir.is_dir():
166
+ return ColumnarCoverage(columnar_sessions=0, json_sessions=0)
167
+ columnar = json_only = 0
168
+ for session_dir in sorted(sessions_dir.iterdir()):
169
+ if not session_dir.is_dir() or session_id_rejection(session_dir.name) is not None:
170
+ # The registry registers nothing from a dir whose name fails the
171
+ # session id boundary, so the coverage count must not see it either.
172
+ continue
173
+ try:
174
+ meta = json.loads((session_dir / "meta.json").read_text(encoding="utf-8"))
175
+ except (OSError, ValueError):
176
+ continue
177
+ schema = meta.get(META_COLUMNAR_KEY) if isinstance(meta, dict) else None
178
+ if session_has_columnar(session_dir, schema):
179
+ columnar += 1
180
+ else:
181
+ json_only += 1
182
+ return ColumnarCoverage(columnar_sessions=columnar, json_sessions=json_only)
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Producer
187
+ # ---------------------------------------------------------------------------
188
+
189
+ #: Rows are batched into Arrow record batches by JSON text size, not row
190
+ #: count, so a session of a few huge tool results and a session of thousands
191
+ #: of tiny ones both stay inside a few MB of transient memory. One batch is
192
+ #: one DuckDB statement and one parquet row group. Measured on the 204 MB
193
+ #: session (201 MB of tool-result text): 1 MiB batches hold the producer's
194
+ #: peak at +108 MB over the trajectory dict, 4 MiB batches cost +184 MB, and
195
+ #: 512 KiB batches saved nothing further.
196
+ _BATCH_BYTES: int = 1024 * 1024
197
+
198
+ #: The relation every projection reads. One Arrow batch is registered under
199
+ #: this name per statement and unregistered right after.
200
+ _SOURCE: str = "batch_src"
201
+
202
+ #: Read-only for owner, group, and world: see the module docstring.
203
+ _ARTIFACT_MODE: int = 0o444
204
+
205
+ #: Parquet compression for every artifact.
206
+ _COMPRESSION: str = "zstd"
207
+
208
+ _COMPACT: tuple[str, str] = (",", ":")
209
+
210
+ #: DuckDB types that map to an Arrow type one to one. JSON, TIMESTAMP, and
211
+ #: STRUCT are handled by :func:`_arrow_type` itself.
212
+ _FLAT_ARROW_TYPES: tuple[str, ...] = ("VARCHAR", "BIGINT", "DOUBLE", "BOOLEAN")
213
+
214
+
215
+ def _dumps(value: Any) -> str:
216
+ """Serialize exactly as the corpus writer serializes ``trajectory.json``.
217
+
218
+ Not required for equivalence (DuckDB re-serializes on the way in) but
219
+ keeps the producer's input the same text the JSON path parses.
220
+ """
221
+ return json.dumps(value, separators=_COMPACT)
222
+
223
+
224
+ def _member(obj: Mapping[str, Any], key: str) -> str | None:
225
+ """JSON text of ``obj[key]``, or SQL NULL when the key is absent.
226
+
227
+ Mirrors ``json_extract(step, '$.key')``: a missing key is NULL, a present
228
+ ``null`` is the JSON value ``null``. The two behave the same under every
229
+ downstream cast except ``json_type``, and the message flattener reads
230
+ both as "not an ARRAY", so the distinction is preserved rather than
231
+ reasoned away.
232
+ """
233
+ return _dumps(obj[key]) if key in obj else None
234
+
235
+
236
+ def _step_rows(session_id: str, steps: Iterable[Any]) -> Iterator[tuple[str | None, ...]]:
237
+ for step in steps:
238
+ if not isinstance(step, Mapping):
239
+ continue
240
+ yield (session_id, *(_member(step, key) for key in STEP_MEMBERS))
241
+
242
+
243
+ def _call_rows(session_id: str, steps: Iterable[Any]) -> Iterator[tuple[str | None, ...]]:
244
+ # Mirrors ``WHERE json_extract(step, '$.tool_calls') IS NOT NULL`` then
245
+ # ``UNNEST(json_extract(step, '$.tool_calls[*]'))``: only a list yields rows.
246
+ for step in steps:
247
+ if not isinstance(step, Mapping):
248
+ continue
249
+ calls = step.get("tool_calls")
250
+ if not isinstance(calls, list):
251
+ continue
252
+ step_id, ts = _member(step, "step_id"), _member(step, "timestamp")
253
+ for call in calls:
254
+ yield (session_id, step_id, ts, _dumps(call))
255
+
256
+
257
+ def _result_rows(session_id: str, steps: Iterable[Any]) -> Iterator[tuple[str | None, ...]]:
258
+ for step in steps:
259
+ if not isinstance(step, Mapping):
260
+ continue
261
+ observation = step.get("observation")
262
+ results = observation.get("results") if isinstance(observation, Mapping) else None
263
+ if not isinstance(results, list):
264
+ continue
265
+ step_id, ts = _member(step, "step_id"), _member(step, "timestamp")
266
+ for result in results:
267
+ yield (session_id, step_id, ts, _dumps(result))
268
+
269
+
270
+ def _event_rows(session_id: str, events_jsonl: Path | None) -> Iterator[tuple[str | None, ...]]:
271
+ """One ``(session_id, line)`` row per non-blank line of the events file.
272
+
273
+ The file may be stored compressed. A missing file yields nothing, so the
274
+ artifact is an empty parquet with the right columns: a session written
275
+ before the events artifact existed has none.
276
+ """
277
+ if events_jsonl is None:
278
+ return
279
+ for line in iter_lines(events_jsonl):
280
+ text = line.strip()
281
+ if text:
282
+ yield (session_id, text)
283
+
284
+
285
+ def _batches(rows: Iterable[tuple[str | None, ...]], schema: Any) -> Iterator[Any]:
286
+ """Group rows into ``pyarrow.RecordBatch``es of about :data:`_BATCH_BYTES` of text."""
287
+ import pyarrow as pa
288
+
289
+ width = len(schema)
290
+ columns: list[list[str | None]] = [[] for _ in range(width)]
291
+ size = 0
292
+ for row in rows:
293
+ for index, value in enumerate(row):
294
+ columns[index].append(value)
295
+ if value is not None:
296
+ size += len(value)
297
+ if size >= _BATCH_BYTES:
298
+ yield pa.record_batch(columns, schema=schema)
299
+ columns = [[] for _ in range(width)]
300
+ size = 0
301
+ # Always emit the tail, even when empty: an empty parquet with the right
302
+ # columns is the correct artifact for a session with no tool calls.
303
+ yield pa.record_batch(columns, schema=schema)
304
+
305
+
306
+ def _string_schema(names: Iterable[str]) -> Any:
307
+ import pyarrow as pa
308
+
309
+ return pa.schema([pa.field(name, pa.string()) for name in names])
310
+
311
+
312
+ def _struct_members(struct_type: str) -> tuple[tuple[str, str], ...]:
313
+ """``(name, type)`` members of a flat DuckDB ``STRUCT(a T, b U)`` type string.
314
+
315
+ Only the two flat struct shapes in :data:`SESSION_COLUMNS` reach here, so
316
+ a comma-split over the member list is sufficient.
317
+ """
318
+ inner = struct_type[len("STRUCT(") : -1]
319
+ members: list[tuple[str, str]] = []
320
+ for part in inner.split(","):
321
+ name, sql_type = part.strip().split(" ", 1)
322
+ members.append((name, sql_type))
323
+ return tuple(members)
324
+
325
+
326
+ def _struct_spec(struct_type: str) -> str:
327
+ """Render a DuckDB ``STRUCT`` type as ``json_transform``'s JSON spec."""
328
+ return json.dumps(dict(_struct_members(struct_type)))
329
+
330
+
331
+ def _session_row_sql(session_id_literal: SqlFragment) -> SqlFragment:
332
+ """The one-row ``session.parquet`` projection over the registered batch.
333
+
334
+ Each trajectory member arrives as JSON text; ``json_transform`` types the
335
+ two structs with the same shape the JSON reader projects them to, so the
336
+ ``sessions`` view reads identical struct fields either way.
337
+ """
338
+ typed: list[str] = [f"{session_id_literal} AS session_id_path"]
339
+ for name, sql_type in SESSION_COLUMNS[1:]:
340
+ if sql_type.startswith("STRUCT"):
341
+ typed.append(
342
+ f"CAST(json_transform({name}, {sql_literal(_struct_spec(sql_type))}) AS {sql_type}) AS {name}"
343
+ )
344
+ elif sql_type == "JSON":
345
+ typed.append(f"json_extract({name}, '$') AS {name}")
346
+ else:
347
+ typed.append(f"json_extract_string({name}, '$')::{sql_type} AS {name}")
348
+ # Every name and type comes from SESSION_COLUMNS; the only literal is the
349
+ # sql_literal-escaped session id.
350
+ return SqlFragment("SELECT " + ", ".join(typed) + f" FROM {_SOURCE}") # noqa: S608 # nosec B608 - SESSION_COLUMNS constants; the id came through sql_literal
351
+
352
+
353
+ def _arrow_type(sql_type: str) -> Any:
354
+ """The Arrow type DuckDB's ``read_parquet`` maps back to exactly ``sql_type``.
355
+
356
+ JSON is the canonical ``arrow.json`` extension (parquet's JSON logical
357
+ type, which DuckDB reads as JSON), TIMESTAMP is microseconds without a
358
+ zone (DuckDB's own TIMESTAMP), and a STRUCT is built member by member.
359
+ Anything else the catalog might grow is a hard error here rather than a
360
+ silently wrong file.
361
+ """
362
+ import pyarrow as pa
363
+
364
+ if sql_type == "JSON":
365
+ return pa.json_(pa.string())
366
+ if sql_type == "TIMESTAMP":
367
+ return pa.timestamp("us")
368
+ if sql_type.startswith("STRUCT("):
369
+ return pa.struct([pa.field(name, _arrow_type(t)) for name, t in _struct_members(sql_type)])
370
+ if sql_type in _FLAT_ARROW_TYPES:
371
+ flat = {
372
+ "VARCHAR": pa.string(),
373
+ "BIGINT": pa.int64(),
374
+ "DOUBLE": pa.float64(),
375
+ "BOOLEAN": pa.bool_(),
376
+ }
377
+ return flat[sql_type]
378
+ msg = f"columnar producer has no Arrow mapping for DuckDB type {sql_type!r}"
379
+ raise ValueError(msg)
380
+
381
+
382
+ def _arrow_schema(columns: Sequence[tuple[str, str]]) -> Any:
383
+ import pyarrow as pa
384
+
385
+ return pa.schema([pa.field(name, _arrow_type(sql_type)) for name, sql_type in columns])
386
+
387
+
388
+ def _fetch_select(columns: Sequence[tuple[str, str]], inner_sql: SqlFragment) -> SqlFragment:
389
+ """Wrap a projection so its rows cross into Python losslessly.
390
+
391
+ Columns are picked by name in catalog order, so the file's column order is
392
+ the catalog's whatever order the projection spells them in. TIMESTAMP
393
+ columns travel as ``epoch_us`` integers (see the module docstring); a
394
+ STRUCT member typed TIMESTAMP would need the same treatment and has no
395
+ caller today, so it is refused rather than mis-typed.
396
+ """
397
+ picks: list[str] = []
398
+ for name, sql_type in columns:
399
+ if sql_type == "TIMESTAMP":
400
+ picks.append(f'epoch_us("{name}") AS "{name}"')
401
+ continue
402
+ if sql_type.startswith("STRUCT(") and any(
403
+ member_type == "TIMESTAMP" for _, member_type in _struct_members(sql_type)
404
+ ):
405
+ msg = f"columnar producer cannot fetch a TIMESTAMP struct member: {name} {sql_type}"
406
+ raise ValueError(msg)
407
+ picks.append(f'"{name}"')
408
+ # Column names are catalog constants; inner_sql is built from constants.
409
+ return SqlFragment(f"SELECT {', '.join(picks)} FROM ({inner_sql})") # noqa: S608 # nosec B608 - catalog column names over a constant-built inner SELECT
410
+
411
+
412
+ def _arrow_array(values: list[Any], sql_type: str) -> Any:
413
+ """Type one fetched column (Python objects from DuckDB) as :func:`_arrow_type` says."""
414
+ import pyarrow as pa
415
+
416
+ if sql_type == "JSON":
417
+ # DuckDB hands JSON to Python as its normalized text.
418
+ return pa.ExtensionArray.from_storage(pa.json_(pa.string()), pa.array(values, pa.string()))
419
+ if sql_type == "TIMESTAMP":
420
+ return pa.array(values, pa.int64()).cast(pa.timestamp("us"))
421
+ if sql_type.startswith("STRUCT("):
422
+ # DuckDB hands a STRUCT to Python as a dict (or None for a NULL struct).
423
+ members = _struct_members(sql_type)
424
+ arrays = [
425
+ _arrow_array([None if value is None else value[name] for value in values], member_type)
426
+ for name, member_type in members
427
+ ]
428
+ fields = [pa.field(name, _arrow_type(member_type)) for name, member_type in members]
429
+ mask = pa.array([value is None for value in values], pa.bool_())
430
+ return pa.StructArray.from_arrays(arrays, fields=fields, mask=mask)
431
+ return pa.array(values, _arrow_type(sql_type))
432
+
433
+
434
+ def _arrow_table(
435
+ rows: list[tuple[Any, ...]], columns: Sequence[tuple[str, str]], schema: Any
436
+ ) -> Any:
437
+ import pyarrow as pa
438
+
439
+ fetched: list[list[Any]] = [[] for _ in columns]
440
+ for row in rows:
441
+ for index, value in enumerate(row):
442
+ fetched[index].append(value)
443
+ arrays = [
444
+ _arrow_array(values, sql_type)
445
+ for values, (_, sql_type) in zip(fetched, columns, strict=True)
446
+ ]
447
+ return pa.table(arrays, schema=schema)
448
+
449
+
450
+ class ColumnarArtifactProducer:
451
+ """Write a session's typed columnar artifacts into its staged directory.
452
+
453
+ Satisfies atif-corpus's ``ArtifactProducer`` port structurally (the two
454
+ packages may not import each other). Each call opens its own in-memory
455
+ DuckDB connection and closes it before returning: DuckDB's allocator
456
+ keeps a connection's freed memory resident, and over a pass of hundreds
457
+ of sessions a shared connection measured 50 MB higher at peak and 140 MB
458
+ higher between sessions than a fresh one, against 9 ms to open one.
459
+ ``duckdb`` and ``pyarrow`` are imported on first use so importing this
460
+ module stays cheap for the commands that only detect artifacts.
461
+ """
462
+
463
+ def __init__(self, *, threads: int = 2, durable: bool = True) -> None:
464
+ #: DuckDB worker threads for the per-batch projections. Two is enough
465
+ #: to overlap Python's batch production with DuckDB's parsing; more
466
+ #: only adds in-flight allocations to the peak.
467
+ self.threads = threads
468
+ #: fsync each file before returning. The lake loader stages files it
469
+ #: deletes a moment later and turns this off.
470
+ self.durable = durable
471
+
472
+ def _connection(self) -> duckdb.DuckDBPyConnection:
473
+ import duckdb
474
+
475
+ con = duckdb.connect()
476
+ con.execute(f"SET threads={int(self.threads)}")
477
+ return con
478
+
479
+ def produce(
480
+ self,
481
+ session_dir: Path,
482
+ *,
483
+ session_id: str,
484
+ trajectory: Mapping[str, Any],
485
+ events_path: Path | None = None,
486
+ ) -> Mapping[str, Any]:
487
+ """Write the five parquet files into ``session_dir``; return the meta extras.
488
+
489
+ Parameters
490
+ ----------
491
+ session_dir
492
+ The STAGED session directory (the corpus writer swaps it into
493
+ ``sessions/<session_id>/`` afterwards, so no path inside the
494
+ files depends on where the directory ends up).
495
+ session_id
496
+ The canonical session key (the directory name the corpus writer
497
+ will publish under), stamped into every row as ``session_id``.
498
+ trajectory
499
+ The ATIF trajectory dict the corpus writer just serialized.
500
+ events_path
501
+ The session's events file (plain or compressed). Default: the
502
+ ``session_events.jsonl`` staged in ``session_dir``.
503
+
504
+ Returns
505
+ -------
506
+ Mapping[str, Any]
507
+ ``{"columnar_schema": COLUMNAR_SCHEMA_VERSION}`` for ``meta.json``.
508
+
509
+ Raises
510
+ ------
511
+ duckdb.Error, OSError, pyarrow.ArrowException
512
+ Propagated: the corpus writer records the session as failed and
513
+ the staged directory never publishes.
514
+ """
515
+ con = self._connection()
516
+ events = session_dir / SESSION_EVENTS_JSONL if events_path is None else events_path
517
+ try:
518
+ self._produce(con, session_dir, session_id, trajectory, events, durable=self.durable)
519
+ finally:
520
+ con.close()
521
+ logger.debug("columnar: wrote {} for session {}", COLUMNAR_FILENAMES, session_id)
522
+ return {META_COLUMNAR_KEY: COLUMNAR_SCHEMA_VERSION}
523
+
524
+ @staticmethod
525
+ def _produce(
526
+ con: duckdb.DuckDBPyConnection,
527
+ session_dir: Path,
528
+ session_id: str,
529
+ trajectory: Mapping[str, Any],
530
+ events_path: Path,
531
+ *,
532
+ durable: bool,
533
+ ) -> None:
534
+ steps = trajectory.get("steps")
535
+ step_list: list[Any] = steps if isinstance(steps, list) else []
536
+ session_literal = sql_literal(session_id)
537
+
538
+ session_schema = _string_schema(name for name, _ in SESSION_COLUMNS[1:])
539
+ session_row = tuple(_member(trajectory, name) for name, _ in SESSION_COLUMNS[1:])
540
+ ColumnarArtifactProducer._write(
541
+ con,
542
+ _batches([session_row], session_schema),
543
+ _session_row_sql(session_literal),
544
+ COLUMNAR_SCHEMAS[SESSION_PARQUET],
545
+ session_dir / SESSION_PARQUET,
546
+ durable=durable,
547
+ )
548
+
549
+ steps_schema = _string_schema(("session_id", *STEP_MEMBERS))
550
+ ColumnarArtifactProducer._write(
551
+ con,
552
+ _batches(_step_rows(session_id, step_list), steps_schema),
553
+ SqlFragment(
554
+ f"SELECT session_id, {render(step_columns(MEMBER_COLUMNS))} FROM {_SOURCE}" # noqa: S608 # nosec B608 - projections constants
555
+ ),
556
+ COLUMNAR_SCHEMAS[STEPS_PARQUET],
557
+ session_dir / STEPS_PARQUET,
558
+ durable=durable,
559
+ )
560
+
561
+ calls_schema = _string_schema(("session_id", "step_id", "timestamp", "call"))
562
+ ColumnarArtifactProducer._write(
563
+ con,
564
+ _batches(_call_rows(session_id, step_list), calls_schema),
565
+ SqlFragment(
566
+ "SELECT session_id, " # noqa: S608 # nosec B608 - projections constants only
567
+ f"{render(step_key_columns(MEMBER_COLUMNS))}, {render(CALL_COLUMNS)} FROM {_SOURCE}"
568
+ ),
569
+ COLUMNAR_SCHEMAS[TOOL_CALLS_PARQUET],
570
+ session_dir / TOOL_CALLS_PARQUET,
571
+ durable=durable,
572
+ )
573
+
574
+ results_schema = _string_schema(("session_id", "step_id", "timestamp", "res"))
575
+ ColumnarArtifactProducer._write(
576
+ con,
577
+ _batches(_result_rows(session_id, step_list), results_schema),
578
+ SqlFragment(
579
+ "SELECT session_id, " # noqa: S608 # nosec B608 - projections constants only
580
+ f"{render(step_key_columns(MEMBER_COLUMNS))}, {render(RESULT_COLUMNS)} FROM {_SOURCE}"
581
+ ),
582
+ COLUMNAR_SCHEMAS[TOOL_RESULTS_PARQUET],
583
+ session_dir / TOOL_RESULTS_PARQUET,
584
+ durable=durable,
585
+ )
586
+
587
+ events_schema = _string_schema(("session_id", "ev"))
588
+ ColumnarArtifactProducer._write(
589
+ con,
590
+ _batches(_event_rows(session_id, events_path), events_schema),
591
+ SqlFragment(
592
+ f"SELECT session_id, {render(EVENT_COLUMNS)} FROM {_SOURCE}" # noqa: S608 # nosec B608 - projections constants only
593
+ ),
594
+ COLUMNAR_SCHEMAS[SESSION_EVENTS_PARQUET],
595
+ session_dir / SESSION_EVENTS_PARQUET,
596
+ durable=durable,
597
+ )
598
+
599
+ @staticmethod
600
+ def _write(
601
+ con: duckdb.DuckDBPyConnection,
602
+ batches: Iterator[Any],
603
+ select_sql: SqlFragment,
604
+ columns: Sequence[tuple[str, str]],
605
+ target: Path,
606
+ *,
607
+ durable: bool = True,
608
+ ) -> None:
609
+ """Project each batch through DuckDB and append it to ``target`` as one row group."""
610
+ import pyarrow.parquet as pq
611
+
612
+ schema = _arrow_schema(columns)
613
+ fetch_sql = _fetch_select(columns, select_sql)
614
+ writer = pq.ParquetWriter(str(target), schema, compression=_COMPRESSION)
615
+ try:
616
+ for batch in batches:
617
+ con.register(_SOURCE, batch)
618
+ try:
619
+ rows = con.execute(fetch_sql).fetchall()
620
+ finally:
621
+ con.unregister(_SOURCE)
622
+ writer.write_table(_arrow_table(rows, columns, schema))
623
+ finally:
624
+ writer.close()
625
+ # Durability matches the JSON artifacts: bytes on stable storage before
626
+ # meta.json publishes them. Then read-only, per the module docstring.
627
+ if durable:
628
+ fd = os.open(target, os.O_RDONLY)
629
+ try:
630
+ os.fsync(fd)
631
+ finally:
632
+ os.close(fd)
633
+ target.chmod(_ARTIFACT_MODE)
634
+
635
+
636
+ __all__ = [
637
+ "ColumnarArtifactProducer",
638
+ "ColumnarCoverage",
639
+ "columnar_coverage",
640
+ "session_has_columnar",
641
+ ]