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
atif_cli/lake.py ADDED
@@ -0,0 +1,373 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The ``atif-sql lake`` subcommand group: rebuild, verify, status, compact.
4
+
5
+ The lake is the one DuckLake every corpus is queried through
6
+ (:mod:`atif_duck.infrastructure.lake`). ``materialize`` keeps it current one
7
+ batch of published sessions at a time; these commands build it, check it,
8
+ report on it, and keep its file count down.
9
+
10
+ Lean by construction like :mod:`atif_cli.cron`: this module loads eagerly with
11
+ ``atif_cli.app`` (sub-apps must exist at registration time), so everything
12
+ beyond cyclopts and the output helpers is imported inside the command bodies.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Annotated
20
+
21
+ import cyclopts
22
+
23
+ from atif_cli.errors import EXIT_CODES, ClassifiedError
24
+ from atif_cli.output import OutputFormat, emit_error, emit_json, resolve_format
25
+
26
+ if TYPE_CHECKING:
27
+ from atif_duck.infrastructure.lake import LakeCorpus, LakeLayout
28
+ from atif_duck.infrastructure.lake_settings import LakeSettings
29
+
30
+ lake_app = cyclopts.App(
31
+ name="lake",
32
+ help="Build, check and maintain the DuckLake every corpus is queried through.",
33
+ )
34
+
35
+
36
+ def lake_layout(lake_root: Path | None) -> tuple[LakeLayout, LakeSettings]:
37
+ """The lake at ``lake_root`` (default ``ATIF_SQL_LAKE_ROOT``), and the settings it came from."""
38
+ from atif_duck.infrastructure.lake import LakeLayout
39
+ from atif_duck.infrastructure.lake_settings import LakeSettings
40
+
41
+ settings = LakeSettings()
42
+ return LakeLayout(lake_root if lake_root is not None else settings.lake_root), settings
43
+
44
+
45
+ def writer_memory_limit() -> int:
46
+ """The same host- and cgroup-derived cap ``query`` runs under."""
47
+ from atif_cli.app import query_memory_limit_bytes
48
+
49
+ return query_memory_limit_bytes()
50
+
51
+
52
+ def _fail(kind: str, message: str, hint: str, fmt: OutputFormat) -> None:
53
+ err = ClassifiedError(kind=kind, exit_code=EXIT_CODES[kind], message=message, hint=hint)
54
+ emit_error(err, fmt)
55
+ raise SystemExit(err.exit_code)
56
+
57
+
58
+ def discover_corpora(
59
+ layout: LakeLayout, settings: LakeSettings, roots: list[Path] | None
60
+ ) -> list[LakeCorpus]:
61
+ """The corpora to act on: the named roots, else what the lake holds plus every corpus under the base."""
62
+ from atif_duck.infrastructure.lake import LakeCorpus, corpus_agent, registered_corpora
63
+
64
+ if roots:
65
+ return [LakeCorpus(root=root, agent=corpus_agent(root)) for root in roots]
66
+ found: dict[Path, LakeCorpus] = {
67
+ corpus.root: corpus
68
+ for corpus in registered_corpora(layout).values()
69
+ if (corpus.root / "sessions").is_dir()
70
+ }
71
+ base = settings.corpus_base
72
+ if base.is_dir():
73
+ for child in sorted(base.iterdir()):
74
+ if (child / "sessions").is_dir() and child not in found:
75
+ found[child] = LakeCorpus(root=child, agent=corpus_agent(child))
76
+ return sorted(found.values(), key=lambda corpus: corpus.name)
77
+
78
+
79
+ @lake_app.command
80
+ def rebuild(
81
+ *,
82
+ corpus_root: Annotated[list[Path] | None, cyclopts.Parameter(consume_multiple=False)] = None,
83
+ lake_root: Path | None = None,
84
+ fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
85
+ ) -> None:
86
+ """Load every corpus's per-session artifacts into a fresh lake, then swap it into place.
87
+
88
+ Parameters
89
+ ----------
90
+ corpus_root
91
+ A corpus to load; repeat for more. Default: every corpus the current
92
+ lake holds, plus every directory under ``ATIF_SQL_CORPUS_BASE``
93
+ (default ``~/.atif-sql/corpus``) that holds ``sessions/``.
94
+ lake_root
95
+ The lake to rebuild (default ``ATIF_SQL_LAKE_ROOT``, else
96
+ ``~/.atif-sql/lake``).
97
+ fmt
98
+ ``auto`` = human lines on a TTY, JSON on a pipe.
99
+
100
+ Installs the ``ducklake`` DuckDB extension first when it is missing (the
101
+ one network fetch the lake needs; ``query`` never installs it). The new
102
+ lake is built beside the old one under the writer lock and renamed into
103
+ place when complete, so a reader sees one lake or the other.
104
+ """
105
+ from atif_duck.infrastructure.lake import (
106
+ LakeCorpusConflictError,
107
+ install_ducklake_extension,
108
+ rebuild_lake,
109
+ )
110
+
111
+ layout, settings = lake_layout(lake_root)
112
+ corpora = discover_corpora(layout, settings, corpus_root)
113
+ if not corpora:
114
+ _fail(
115
+ "invalid_input",
116
+ "no corpus to load",
117
+ "materialize a corpus first, or pass --corpus-root",
118
+ fmt,
119
+ )
120
+ install_ducklake_extension()
121
+ try:
122
+ report = rebuild_lake(
123
+ layout,
124
+ corpora,
125
+ batch_size=settings.lake_load_batch_size,
126
+ lock_timeout_seconds=settings.lake_lock_timeout_seconds,
127
+ memory_limit_bytes=writer_memory_limit(),
128
+ stage_workers=settings.lake_stage_workers,
129
+ )
130
+ except LakeCorpusConflictError as exc:
131
+ _fail("invalid_input", str(exc), "give each corpus a distinct directory name", fmt)
132
+ return
133
+ payload = {
134
+ "lake_root": str(report.root),
135
+ "corpora": [
136
+ {"corpus": name, "agent": agent, "sessions": sessions}
137
+ for name, agent, sessions in report.corpora
138
+ ],
139
+ "data_files": report.data_files,
140
+ "seconds": round(report.seconds, 3),
141
+ }
142
+ if resolve_format(fmt) is OutputFormat.TABLE:
143
+ for name, agent, sessions in report.corpora:
144
+ print(f"loaded {name} ({agent}): {sessions} sessions")
145
+ print(f"lake: {report.root} data files: {report.data_files} in {report.seconds:.1f}s")
146
+ else:
147
+ emit_json(payload, fmt)
148
+
149
+
150
+ @lake_app.command
151
+ def verify(
152
+ *,
153
+ corpus_root: Annotated[list[Path] | None, cyclopts.Parameter(consume_multiple=False)] = None,
154
+ lake_root: Path | None = None,
155
+ limit: int = 20,
156
+ fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
157
+ ) -> None:
158
+ """Compare every session's lake rows with its per-session artifacts; exit 65 on a difference.
159
+
160
+ Per table and session: the row count and an order-free content hash of
161
+ the lake's rows against the same over what the per-session path reads.
162
+ Exits 0 when all match, 65 (``lake_mismatch``) when any differs, 78
163
+ (``lake_unavailable``) when there is no lake or its schema is stale.
164
+
165
+ Parameters
166
+ ----------
167
+ corpus_root
168
+ Verify only this corpus; repeat for more. Default: every corpus the lake holds.
169
+ lake_root
170
+ The lake to verify (default ``ATIF_SQL_LAKE_ROOT``).
171
+ limit
172
+ How many differences to list (all are counted).
173
+ fmt
174
+ ``auto`` = human lines on a TTY, JSON on a pipe.
175
+ """
176
+ from atif_duck.infrastructure.lake import (
177
+ LakeCorpus,
178
+ LakeError,
179
+ corpus_agent,
180
+ verify_lake,
181
+ )
182
+
183
+ layout, settings = lake_layout(lake_root)
184
+ corpora = (
185
+ [LakeCorpus(root=root, agent=corpus_agent(root)) for root in corpus_root]
186
+ if corpus_root
187
+ else None
188
+ )
189
+ try:
190
+ report = verify_lake(
191
+ layout,
192
+ corpora,
193
+ memory_limit_bytes=writer_memory_limit(),
194
+ batch_size=settings.lake_load_batch_size,
195
+ stage_workers=settings.lake_stage_workers,
196
+ )
197
+ except LakeError as exc:
198
+ _fail("lake_unavailable", str(exc), "run `atif-sql lake rebuild`", fmt)
199
+ return
200
+ if report.stale:
201
+ _fail(
202
+ "lake_unavailable",
203
+ f"cannot verify {layout.root}: {', '.join(report.stale)}",
204
+ "run `atif-sql lake rebuild`",
205
+ fmt,
206
+ )
207
+ sessions = sorted({(m.corpus, m.session_id) for m in report.mismatches})
208
+ payload = {
209
+ "lake_root": str(layout.root),
210
+ "clean": report.clean,
211
+ "corpora": [{"corpus": name, "sessions": n} for name, n in report.corpora],
212
+ "mismatched_sessions": len(sessions),
213
+ "mismatches": [
214
+ {
215
+ "corpus": m.corpus,
216
+ "table": m.table,
217
+ "session_id": m.session_id,
218
+ "artifact_rows": m.artifact_rows,
219
+ "lake_rows": m.lake_rows,
220
+ }
221
+ for m in report.mismatches[: max(0, limit)]
222
+ ],
223
+ }
224
+ if resolve_format(fmt) is OutputFormat.TABLE:
225
+ for name, n in report.corpora:
226
+ print(f"{name}: {n} sessions checked")
227
+ for m in report.mismatches[: max(0, limit)]:
228
+ print(
229
+ f" DIFFERS {m.corpus}/{m.session_id} {m.table}: "
230
+ f"{m.artifact_rows} artifact row(s), {m.lake_rows} lake row(s)",
231
+ file=sys.stderr,
232
+ )
233
+ print("clean" if report.clean else f"{len(sessions)} session(s) differ")
234
+ else:
235
+ emit_json(payload, fmt)
236
+ if not report.clean:
237
+ raise SystemExit(EXIT_CODES["lake_mismatch"])
238
+
239
+
240
+ @lake_app.command
241
+ def status(
242
+ *,
243
+ lake_root: Path | None = None,
244
+ fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
245
+ ) -> None:
246
+ """Report the lake: present, schema current, corpora, snapshots, files, last write.
247
+
248
+ Read from the published reader catalog, so it never waits on a writer.
249
+
250
+ Parameters
251
+ ----------
252
+ lake_root
253
+ The lake to report on (default ``ATIF_SQL_LAKE_ROOT``).
254
+ fmt
255
+ ``auto`` = human lines on a TTY, JSON on a pipe.
256
+ """
257
+ from atif_duck.infrastructure.lake import lake_status
258
+
259
+ layout, _ = lake_layout(lake_root)
260
+ state = lake_status(layout).as_dict()
261
+ if resolve_format(fmt) is OutputFormat.TABLE:
262
+ print(f"lake root: {state['root']}")
263
+ print(
264
+ f"present: {state['present']} (ducklake extension: {state['extension_installed']})"
265
+ )
266
+ if state["present"]:
267
+ print(
268
+ f"schema: {'current' if state['schema_current'] else 'STALE ' + ', '.join(state['stale'])}"
269
+ )
270
+ for corpus in state["corpora"]:
271
+ print(
272
+ f"corpus: {corpus['corpus']} ({corpus['agent']}): {corpus['sessions']} sessions"
273
+ )
274
+ print(f"snapshots: {state['snapshots']}")
275
+ print(
276
+ f"data files: {state['data_files']} ({state['data_bytes']:,} bytes), {state['delete_files']} delete files"
277
+ )
278
+ print(f"last write: {state['last_write']}")
279
+ else:
280
+ emit_json(state, fmt)
281
+
282
+
283
+ @lake_app.command
284
+ def compact(
285
+ *,
286
+ expire_older_than_days: int | None = None,
287
+ memory_limit: str | None = None,
288
+ lake_root: Path | None = None,
289
+ fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
290
+ ) -> None:
291
+ """Merge small files, expire old snapshots, and remove the files nothing references.
292
+
293
+ Parameters
294
+ ----------
295
+ expire_older_than_days
296
+ Expire snapshots older than this (default ``ATIF_SQL_LAKE_EXPIRE_DAYS``,
297
+ else 30). Files only expired snapshots referenced are then removed,
298
+ once an hour old, so a reader in flight keeps every file it names.
299
+ memory_limit
300
+ DuckDB's memory budget for the run, as a size (``2GiB``, ``1500MB``),
301
+ instead of the one derived from the host and cgroup. The writer's own
302
+ 2 GiB ceiling still applies. The nightly refresh lane passes one that
303
+ fits its memory scope, so a sizing mistake can't shrink the budget
304
+ until the merge runs out of memory.
305
+ lake_root
306
+ The lake to compact (default ``ATIF_SQL_LAKE_ROOT``).
307
+ fmt
308
+ ``auto`` = human lines on a TTY, JSON on a pipe.
309
+ """
310
+ from atif_duck.infrastructure.lake import (
311
+ DEFAULT_WRITER_MEMORY_BYTES,
312
+ LakeError,
313
+ compact_lake,
314
+ )
315
+
316
+ layout, settings = lake_layout(lake_root)
317
+ days = (
318
+ expire_older_than_days if expire_older_than_days is not None else settings.lake_expire_days
319
+ )
320
+ if days < 0:
321
+ _fail("invalid_input", "--expire-older-than-days must be >= 0", "pass 0 or more", fmt)
322
+ budget = writer_memory_limit()
323
+ if memory_limit is not None:
324
+ from atif_cli.app import parse_size
325
+
326
+ try:
327
+ budget = parse_size(memory_limit)
328
+ except ValueError as exc:
329
+ _fail("invalid_input", str(exc), "pass a size such as 2GiB or 1500MB", fmt)
330
+ if budget <= 0:
331
+ _fail("invalid_input", "--memory-limit must be a positive size", "e.g. 2GiB", fmt)
332
+ import duckdb
333
+
334
+ try:
335
+ report = compact_lake(
336
+ layout,
337
+ expire_older_than_days=days,
338
+ lock_timeout_seconds=settings.lake_lock_timeout_seconds,
339
+ memory_limit_bytes=budget,
340
+ )
341
+ except LakeError as exc:
342
+ _fail("lake_unavailable", str(exc), "run `atif-sql lake rebuild`", fmt)
343
+ return
344
+ except duckdb.Error as exc:
345
+ # An out-of-memory merge is the likely one. Nothing was published, so
346
+ # readers keep the previous catalog; the next run starts over.
347
+ _fail(
348
+ "runtime_error",
349
+ str(exc).splitlines()[0],
350
+ "raise --memory-limit (the writer's ceiling is 2GiB) or run it again",
351
+ fmt,
352
+ )
353
+ return
354
+ payload = {
355
+ "lake_root": str(layout.root),
356
+ "data_files_before": report.files_before,
357
+ "data_files_after": report.files_after,
358
+ "snapshots_before": report.snapshots_before,
359
+ "snapshots_after": report.snapshots_after,
360
+ "memory_limit_bytes": min(budget, DEFAULT_WRITER_MEMORY_BYTES),
361
+ "seconds": round(report.seconds, 3),
362
+ }
363
+ if resolve_format(fmt) is OutputFormat.TABLE:
364
+ print(
365
+ f"data files: {report.files_before} -> {report.files_after} "
366
+ f"snapshots: {report.snapshots_before} -> {report.snapshots_after} "
367
+ f"in {report.seconds:.1f}s"
368
+ )
369
+ else:
370
+ emit_json(payload, fmt)
371
+
372
+
373
+ __all__ = ["lake_app"]
@@ -0,0 +1,270 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The lake behind atif-analytics' session port: what ``analyze`` reads when the lake holds the corpus.
4
+
5
+ atif-analytics may import no package of ours but atif-models, so it declares
6
+ the port (:class:`atif_analytics.domain.ports.SessionSource`) and atif-cli,
7
+ the composition root, adapts atif-duck's :class:`~atif_duck.infrastructure.lake_sessions.LakeSessionReader`
8
+ to it here. The rows become :class:`~atif_analytics.domain.transcript.StepEvent`
9
+ through :func:`~atif_analytics.domain.transcript.step_event`, the projection
10
+ the file source uses too, so both sources apply one set of rules, and the
11
+ author label comes from atif-analytics' rule table (pinned to atif-duck's
12
+ ``step_author`` by ``test_authorship_twin_pin.py``).
13
+
14
+ Per session, the lake is used only when it holds the session's CURRENT
15
+ artifacts: its ``session_meta`` row must carry the ``materialized_at`` and
16
+ ``source_mtime_ns`` the session's ``meta.json`` carries now. Anything else (a
17
+ session the lake has not loaded, one a failed or skipped lake write left
18
+ behind, one whose ``meta.json`` cannot be read) is read from its files, so a
19
+ lagging lake can never hand the pipelines an older transcript than the files
20
+ hold. Enumeration and the ``trajectory.json`` mtime bound come from the
21
+ session directories exactly as on the file path, which keeps the checkpoint's
22
+ bounds identical whichever source ran.
23
+
24
+ Order inside that per-session check matters: the trajectory is stat'ed before
25
+ ``meta.json`` is read, so a materialize swapping the session in between leaves
26
+ an OLD mtime beside whatever was read, and the checkpoint re-admits the
27
+ session next run rather than skipping a transcript it never analyzed.
28
+
29
+ A lake read that fails mid-run (a ``lake rebuild`` swapped the lake out from
30
+ under the open attach) logs one warning, and that read and every later one
31
+ come from the files, so a rebuild never takes a stage down.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import dataclasses
37
+ import json
38
+ from collections import defaultdict
39
+ from typing import TYPE_CHECKING, Any
40
+
41
+ from loguru import logger
42
+
43
+ from atif_analytics.domain.transcript import StepEvent, step_event
44
+ from atif_analytics.infrastructure.corpus_reader import (
45
+ META_FILENAME,
46
+ TrajectoryFileSource,
47
+ complete_session_dirs,
48
+ last_edge_ts,
49
+ trajectory_mtime,
50
+ )
51
+ from atif_duck.infrastructure.lake_sessions import LakeReadError
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Sequence
55
+ from datetime import datetime
56
+ from pathlib import Path
57
+
58
+ from atif_analytics.domain.ports import SessionBounds
59
+ from atif_duck.infrastructure.lake import LakeLayout, LakeUnavailable
60
+ from atif_duck.infrastructure.lake_sessions import LakeSessionReader, SessionFreshness
61
+
62
+ #: Sessions per gate-level lake read. One read costs about the same for one
63
+ #: session as for this many (it scans the same row groups' metadata), and the
64
+ #: gates walk the newest-first order, so the reader reads ahead by this much.
65
+ TURNS_BATCH_SIZE: int = 64
66
+
67
+ #: Sessions per full read (the tool payloads). Smaller: a batch of results can
68
+ #: be large, and the renderer only reads what it admits.
69
+ STEPS_BATCH_SIZE: int = 4
70
+
71
+
72
+ def _json(text: str | None) -> Any:
73
+ return None if text is None else json.loads(text)
74
+
75
+
76
+ def _is_current(session_dir: Path, recorded: SessionFreshness | None) -> bool:
77
+ """True when the lake's row for this session is its current ``meta.json``'s."""
78
+ if recorded is None:
79
+ return False
80
+ try:
81
+ meta = json.loads((session_dir / META_FILENAME).read_text())
82
+ except (OSError, ValueError):
83
+ return False
84
+ if not isinstance(meta, dict):
85
+ return False
86
+ return bool(
87
+ meta.get("materialized_at") == recorded.materialized_at
88
+ and meta.get("source_mtime_ns") == recorded.source_mtime_ns
89
+ )
90
+
91
+
92
+ class LakeSessionSource:
93
+ """atif-analytics' ``SessionSource`` over the lake, falling back to the files per session."""
94
+
95
+ name: str = "lake"
96
+ turns_batch_size: int = TURNS_BATCH_SIZE
97
+ steps_batch_size: int = STEPS_BATCH_SIZE
98
+ turns_are_complete: bool = False
99
+
100
+ def __init__(self, reader: LakeSessionReader, corpus_root: Path) -> None:
101
+ self._reader = reader
102
+ self._sessions_dir = corpus_root / "sessions"
103
+ self._files = TrajectoryFileSource(corpus_root)
104
+ self._current: set[str] | None = None
105
+ #: Sessions the last enumeration found on disk but read from files.
106
+ self.from_files: tuple[str, ...] = ()
107
+ #: True once a lake read failed; the rest of the run reads files.
108
+ self.failed: bool = False
109
+
110
+ @classmethod
111
+ def open(
112
+ cls, layout: LakeLayout, corpus_root: Path, *, memory_limit_bytes: int | None = None
113
+ ) -> LakeSessionSource | LakeUnavailable:
114
+ """Attach the lake for ``corpus_root``, or return why it cannot serve it."""
115
+ from atif_duck.infrastructure.lake_sessions import LakeSessionReader
116
+
117
+ reader = LakeSessionReader.open(layout, corpus_root, memory_limit_bytes=memory_limit_bytes)
118
+ if not isinstance(reader, LakeSessionReader):
119
+ return reader
120
+ return cls(reader, corpus_root)
121
+
122
+ def close(self) -> None:
123
+ """Release the lake connection."""
124
+ self._reader.close()
125
+
126
+ # ------------------------------------------------------------------
127
+ # Enumeration
128
+ # ------------------------------------------------------------------
129
+
130
+ def session_bounds(self) -> SessionBounds:
131
+ """Every complete session's bounds; the lake supplies the newest-record bound when current."""
132
+ freshness: dict[str, SessionFreshness] = {}
133
+ newest: dict[str, datetime | None] = {}
134
+ if not self.failed:
135
+ try:
136
+ freshness = self._reader.freshness()
137
+ newest = self._reader.last_edge_ts()
138
+ except LakeReadError as exc:
139
+ self._fail(exc)
140
+ freshness.clear()
141
+ newest.clear()
142
+ bounds: SessionBounds = {}
143
+ current: set[str] = set()
144
+ from_files: list[str] = []
145
+ for session_dir in complete_session_dirs(self._sessions_dir):
146
+ sid = session_dir.name
147
+ mtime = trajectory_mtime(session_dir)
148
+ if _is_current(session_dir, freshness.get(sid)):
149
+ current.add(sid)
150
+ bounds[sid] = (newest.get(sid), mtime)
151
+ else:
152
+ from_files.append(sid)
153
+ bounds[sid] = (last_edge_ts(session_dir), mtime)
154
+ self._current = current
155
+ self.from_files = tuple(from_files)
156
+ return bounds
157
+
158
+ def _fail(self, exc: LakeReadError) -> None:
159
+ """Stop reading the lake for the rest of the run; every later read comes from files."""
160
+ if not self.failed:
161
+ logger.warning(
162
+ "analyze: a lake read failed ({}); reading the per-session artifacts for the rest of the run",
163
+ exc,
164
+ )
165
+ self.failed = True
166
+ self._current = set()
167
+
168
+ def _split(self, session_ids: Sequence[str]) -> tuple[list[str], list[str]]:
169
+ """``(read from the lake, read from files)``, enumerating first if nothing has."""
170
+ if self._current is None:
171
+ self.session_bounds()
172
+ current = self._current or set()
173
+ in_lake = [sid for sid in session_ids if sid in current]
174
+ on_disk = [sid for sid in session_ids if sid not in current]
175
+ return in_lake, on_disk
176
+
177
+ def batchable(self, session_id: str) -> bool:
178
+ """True for a session read from the lake; a file fallback is parsed only on request."""
179
+ return self._current is not None and session_id in self._current
180
+
181
+ # ------------------------------------------------------------------
182
+ # Steps
183
+ # ------------------------------------------------------------------
184
+
185
+ def load_turns(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
186
+ """Each session's steps without tool payloads, error flags included."""
187
+ in_lake, on_disk = self._split(session_ids)
188
+ try:
189
+ from_lake = self._lake_turns(in_lake)
190
+ except LakeReadError as exc:
191
+ self._fail(exc)
192
+ from_lake, on_disk = {}, list(session_ids)
193
+ # A fallback parse yields the payloads; the turns memo holds many
194
+ # sessions, so keep it as small as the lake's rows.
195
+ out = {
196
+ sid: [dataclasses.replace(s, tool_calls=[], tool_results=[]) for s in steps]
197
+ for sid, steps in self._files.load_turns(on_disk).items()
198
+ }
199
+ out.update(from_lake)
200
+ return out
201
+
202
+ def _lake_turns(self, in_lake: list[str]) -> dict[str, list[StepEvent]]:
203
+ errors = self._reader.error_steps(in_lake)
204
+ grouped: dict[str, list[StepEvent]] = defaultdict(list)
205
+ for sid, step_id, ts, source, message, sidechain, compact, uuids in self._reader.turns(
206
+ in_lake
207
+ ):
208
+ grouped[str(sid)].append(
209
+ step_event(
210
+ ts=ts,
211
+ source=source,
212
+ text=message,
213
+ source_uuids=_json(uuids),
214
+ is_sidechain=bool(sidechain),
215
+ is_compact_summary=bool(compact),
216
+ has_error_result=(str(sid), int(step_id)) in errors,
217
+ )
218
+ )
219
+ return {sid: grouped.get(sid, []) for sid in in_lake}
220
+
221
+ def load_steps(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
222
+ """Each session's steps with every tool call and result, in source order."""
223
+ in_lake, on_disk = self._split(session_ids)
224
+ try:
225
+ from_lake = self._lake_steps(in_lake)
226
+ except LakeReadError as exc:
227
+ self._fail(exc)
228
+ from_lake, on_disk = {}, list(session_ids)
229
+ out: dict[str, list[StepEvent]] = dict(self._files.load_steps(on_disk))
230
+ out.update(from_lake)
231
+ return out
232
+
233
+ def _lake_steps(self, in_lake: list[str]) -> dict[str, list[StepEvent]]:
234
+ calls: dict[tuple[str, int], list[tuple[str | None, object]]] = defaultdict(list)
235
+ for sid, step_id, name, tool_input in self._reader.tool_calls(in_lake):
236
+ calls[(str(sid), int(step_id))].append((name, _json(tool_input)))
237
+ results: dict[tuple[str, int], list[tuple[str | None, object, bool]]] = defaultdict(list)
238
+ for sid, step_id, call_id, content, is_error in self._reader.tool_results(in_lake):
239
+ results[(str(sid), int(step_id))].append((call_id, _json(content), is_error is True))
240
+ grouped: dict[str, list[StepEvent]] = defaultdict(list)
241
+ for sid, step_id, ts, source, message, sidechain, compact, uuids in self._reader.turns(
242
+ in_lake
243
+ ):
244
+ key = (str(sid), int(step_id))
245
+ grouped[key[0]].append(
246
+ step_event(
247
+ ts=ts,
248
+ source=source,
249
+ text=message,
250
+ source_uuids=_json(uuids),
251
+ is_sidechain=bool(sidechain),
252
+ is_compact_summary=bool(compact),
253
+ tool_calls=calls.get(key, ()),
254
+ tool_results=results.get(key, ()),
255
+ )
256
+ )
257
+ return {sid: grouped.get(sid, []) for sid in in_lake}
258
+
259
+ def edges_uuids(self, session_id: str) -> set[str] | None:
260
+ """The session's raw-record uuids, from the lake when it holds the session's current rows."""
261
+ in_lake, _ = self._split([session_id])
262
+ if in_lake:
263
+ try:
264
+ return self._reader.edge_uuids(session_id)
265
+ except LakeReadError as exc:
266
+ self._fail(exc)
267
+ return self._files.edges_uuids(session_id)
268
+
269
+
270
+ __all__ = ["STEPS_BATCH_SIZE", "TURNS_BATCH_SIZE", "LakeSessionSource"]