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,546 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The corpus reader the pipelines share, and the per-session file source under it.
4
+
5
+ :class:`CorpusReader` is what every pipeline walks: the window filter, the
6
+ newest-first order, the bounded memos and the transcript renderer, over one
7
+ :class:`~atif_analytics.domain.ports.SessionSource`. The default source is
8
+ :class:`TrajectoryFileSource`, which parses each session directory's
9
+ ``trajectory.json`` steps and scans its ``edges.jsonl`` (CONTRACT-V2: the
10
+ pipelines read the MATERIALIZED corpus, not raw JSONL). atif-cli plugs in the
11
+ lake source instead when the lake holds the corpus.
12
+
13
+ Torn-set guard: like atif-duck, a session directory is visible only when
14
+ ``meta.json`` exists (the corpus writer lands it last), so a crashed
15
+ writer's partial dir contributes nothing.
16
+
17
+ Stored names: materialize stores ``trajectory.json`` and ``edges.jsonl``
18
+ zstd-compressed, as ``<name>.zst``, and a corpus written before that keeps
19
+ the plain files until ``atif-sql corpus slim`` compresses them. Every reader
20
+ here resolves the stored file per session (:func:`stored_file`, compressed
21
+ first) and reads the same bytes either way. ``corpus slim`` keeps the plain
22
+ file's mtime on the compressed one, so the mtime bound doesn't move.
23
+
24
+ Session enumeration reads timestamps from ``edges.jsonl``, never from the
25
+ parsed steps: :meth:`CorpusReader.session_bounds` runs over every session
26
+ in the window on every tick, and parsing a trajectory (tool_result bodies
27
+ included) to learn one timestamp makes the cheap checkpoint skip cost as
28
+ much as the work it avoids.
29
+
30
+ Semantic notes (mirrors atif-duck's ``steps`` view; the shared projection is
31
+ :func:`~atif_analytics.domain.transcript.step_event`):
32
+
33
+ * ``Step.message`` is ``str | ContentPart[]``; the list branch joins the
34
+ parts' ``text`` fields with blank lines.
35
+ * the step uuid is ``extra.source_uuids[0]`` — the FIRST source uuid, the
36
+ documented primary raw-record key (same choice the VSS branch embeds on).
37
+ * ATIF ``source`` maps ``agent`` → ``assistant`` for the prompt surface, and
38
+ every user step carries its ``author``
39
+ (:mod:`atif_analytics.domain.authorship`).
40
+ * a result is an error when the converter's typed ``extra.is_error`` says so
41
+ (the flag the lake and the ``tool_results`` view carry), or when harbor's
42
+ ``extra.tool_result_metadata.is_error`` does (the raw Claude Code flag, the
43
+ one this reader read before the typed flag existed; the two agree on every
44
+ Claude Code result, and Codex results carry only the typed one).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import io
50
+ import json
51
+ from collections import OrderedDict
52
+ from datetime import UTC, datetime
53
+ from pathlib import Path
54
+ from typing import TYPE_CHECKING, Any
55
+
56
+ from loguru import logger
57
+
58
+ from atif_analytics.domain.config import TranscriptCaps
59
+ from atif_analytics.domain.transcript import (
60
+ StepEvent,
61
+ render_session_text,
62
+ session_kind,
63
+ step_event,
64
+ )
65
+
66
+ if TYPE_CHECKING:
67
+ from collections.abc import Iterable, Iterator, Sequence
68
+
69
+ from atif_analytics.domain.authorship import SessionKind
70
+ from atif_analytics.domain.ports import SessionBounds, SessionSource
71
+
72
+ TRAJECTORY_FILENAME = "trajectory.json"
73
+ EDGES_FILENAME = "edges.jsonl"
74
+ META_FILENAME = "meta.json"
75
+ #: The suffix a compressed artifact carries (twin of atif-corpus's, pinned in
76
+ #: atif-cli's tests).
77
+ COMPRESSED_SUFFIX = ".zst"
78
+
79
+ #: Parsed-step memo capacity. A single trajectory can decode to tens of MB
80
+ #: of tool_result strings, so this is an LRU rather than a grow-forever
81
+ #: dict: an unbounded memo pins the whole admitted corpus in RSS for the
82
+ #: run. The window only has to cover ONE session's intra-stage re-reads
83
+ #: (eligibility probe → render → uuid re-walk), not the whole batch.
84
+ DEFAULT_STEPS_CACHE_SIZE = 8
85
+
86
+
87
+ def parse_ts(raw: str | None) -> datetime | None:
88
+ """Parse an ATIF ISO-8601 timestamp (``...Z``) to an aware UTC datetime."""
89
+ if not raw:
90
+ return None
91
+ try:
92
+ dt = datetime.fromisoformat(raw)
93
+ except ValueError:
94
+ return None
95
+ if dt.tzinfo is None:
96
+ dt = dt.replace(tzinfo=UTC)
97
+ return dt.astimezone(UTC)
98
+
99
+
100
+ def _flatten_message(message: Any) -> str:
101
+ """Flatten ATIF ``Step.message`` (str | ContentPart[]) to one text body."""
102
+ if isinstance(message, str):
103
+ return message
104
+ if isinstance(message, list):
105
+ parts: list[str] = []
106
+ for part in message:
107
+ if isinstance(part, dict):
108
+ text = part.get("text")
109
+ if isinstance(text, str) and text:
110
+ parts.append(text)
111
+ return "\n\n".join(parts)
112
+ return ""
113
+
114
+
115
+ def parse_trajectory_step(step: dict[str, Any]) -> StepEvent:
116
+ """Project one raw ``trajectory.json`` step dict into a :class:`StepEvent`."""
117
+ extra = step.get("extra") or {}
118
+ calls: list[tuple[str | None, object]] = [
119
+ (call.get("function_name"), call.get("arguments"))
120
+ for call in step.get("tool_calls") or []
121
+ if isinstance(call, dict)
122
+ ]
123
+ results: list[tuple[str | None, object, bool]] = []
124
+ observation = step.get("observation") or {}
125
+ for res in observation.get("results") or []:
126
+ if not isinstance(res, dict):
127
+ continue
128
+ res_extra = res.get("extra") or {}
129
+ meta = res_extra.get("tool_result_metadata") or {}
130
+ is_error = res_extra.get("is_error") is True or meta.get("is_error") is True
131
+ results.append((res.get("source_call_id"), res.get("content"), is_error))
132
+ return step_event(
133
+ ts=parse_ts(str(step.get("timestamp") or "")),
134
+ source=str(step.get("source") or ""),
135
+ text=_flatten_message(step.get("message")),
136
+ source_uuids=extra.get("source_uuids"),
137
+ is_sidechain=bool(extra.get("is_sidechain") or False),
138
+ is_compact_summary=bool(extra.get("is_compact_summary") or False),
139
+ tool_calls=calls,
140
+ tool_results=results,
141
+ )
142
+
143
+
144
+ def stored_file(session_dir: Path, name: str) -> Path | None:
145
+ """The file ``name`` is stored under (``<name>.zst`` first, then ``<name>``), or ``None``."""
146
+ for candidate in (f"{name}{COMPRESSED_SUFFIX}", name):
147
+ path = session_dir / candidate
148
+ if path.is_file():
149
+ return path
150
+ return None
151
+
152
+
153
+ def _read_bytes(path: Path) -> bytes:
154
+ if not path.name.endswith(COMPRESSED_SUFFIX):
155
+ return path.read_bytes()
156
+ import zstandard
157
+
158
+ with path.open("rb") as handle, zstandard.ZstdDecompressor().stream_reader(handle) as reader:
159
+ return reader.readall()
160
+
161
+
162
+ def _lines(path: Path) -> Iterable[str]:
163
+ """``path``'s decompressed text, line by line."""
164
+ with path.open("rb") as raw:
165
+ if path.name.endswith(COMPRESSED_SUFFIX):
166
+ import zstandard
167
+
168
+ with (
169
+ zstandard.ZstdDecompressor().stream_reader(raw) as reader,
170
+ io.TextIOWrapper(reader, encoding="utf-8") as text,
171
+ ):
172
+ yield from text
173
+ else:
174
+ with io.TextIOWrapper(raw, encoding="utf-8") as text:
175
+ yield from text
176
+
177
+
178
+ def complete_session_dirs(sessions_dir: Path) -> list[Path]:
179
+ """Session dirs carrying meta.json + a stored trajectory (torn-set gate), sorted."""
180
+ if not sessions_dir.is_dir():
181
+ return []
182
+ out: list[Path] = []
183
+ for d in sorted(sessions_dir.iterdir()):
184
+ if not d.is_dir():
185
+ continue
186
+ if not (d / META_FILENAME).exists():
187
+ logger.warning("Skipping incomplete session dir {} (no meta.json)", d)
188
+ continue
189
+ if stored_file(d, TRAJECTORY_FILENAME) is not None:
190
+ out.append(d)
191
+ return out
192
+
193
+
194
+ def last_edge_ts(session_dir: Path) -> datetime | None:
195
+ """Newest record timestamp from one session's ``edges.jsonl``.
196
+
197
+ The edges file is one small JSON object per raw record — no message
198
+ bodies, no tool_result content — so this is the cheap bound.
199
+ atif-converter sorts edges by ``(ts, uuid)``, but the scan takes the
200
+ max rather than the last line so a hand-written or re-ordered file
201
+ still yields the true newest timestamp.
202
+ """
203
+ path = stored_file(session_dir, EDGES_FILENAME) or session_dir / EDGES_FILENAME
204
+ newest: datetime | None = None
205
+ try:
206
+ for raw_line in _lines(path):
207
+ line = raw_line.strip()
208
+ if not line:
209
+ continue
210
+ try:
211
+ rec = json.loads(line)
212
+ except json.JSONDecodeError:
213
+ continue
214
+ ts = parse_ts(rec.get("ts")) if isinstance(rec, dict) else None
215
+ if ts is not None and (newest is None or ts > newest):
216
+ newest = ts
217
+ except OSError as exc:
218
+ logger.warning(
219
+ "corpus_reader: unreadable edges for {} ({}); no last_ts bound",
220
+ session_dir.name,
221
+ exc,
222
+ )
223
+ return newest
224
+
225
+
226
+ def trajectory_mtime(session_dir: Path) -> datetime | None:
227
+ """The stored trajectory's mtime: the bound that moves when materialize rewrites a session."""
228
+ path = stored_file(session_dir, TRAJECTORY_FILENAME)
229
+ if path is None:
230
+ return None
231
+ try:
232
+ st = path.stat()
233
+ except OSError:
234
+ return None
235
+ return datetime.fromtimestamp(st.st_mtime, tz=UTC)
236
+
237
+
238
+ def read_trajectory_steps(session_dir: Path) -> list[StepEvent]:
239
+ """Parse one session's stored trajectory steps (empty when unreadable)."""
240
+ path = stored_file(session_dir, TRAJECTORY_FILENAME) or session_dir / TRAJECTORY_FILENAME
241
+ try:
242
+ doc = json.loads(_read_bytes(path))
243
+ except (OSError, ValueError) as exc:
244
+ logger.warning("corpus_reader: unreadable trajectory for {} ({})", session_dir.name, exc)
245
+ return []
246
+ return [parse_trajectory_step(s) for s in (doc.get("steps") or []) if isinstance(s, dict)]
247
+
248
+
249
+ def read_edges_uuids(session_dir: Path) -> set[str] | None:
250
+ """Non-null raw-record uuids from ``edges.jsonl``, or ``None`` if unreadable."""
251
+ path = stored_file(session_dir, EDGES_FILENAME) or session_dir / EDGES_FILENAME
252
+ out: set[str] = set()
253
+ try:
254
+ for raw_line in _lines(path):
255
+ line = raw_line.strip()
256
+ if not line:
257
+ continue
258
+ try:
259
+ rec = json.loads(line)
260
+ except json.JSONDecodeError:
261
+ continue
262
+ uuid = rec.get("uuid")
263
+ if isinstance(uuid, str) and uuid:
264
+ out.add(uuid)
265
+ except OSError as exc:
266
+ logger.warning("corpus_reader: unreadable edges for {} ({})", session_dir.name, exc)
267
+ return None
268
+ return out
269
+
270
+
271
+ class TrajectoryFileSource:
272
+ """The per-session file source: ``trajectory.json`` steps, ``edges.jsonl`` bounds.
273
+
274
+ Parsing costs per session, so it never reads ahead (batch size 1), and a
275
+ trajectory parse yields the tool payloads anyway, so :meth:`load_turns`
276
+ is :meth:`load_steps`.
277
+ """
278
+
279
+ name: str = "files"
280
+ turns_batch_size: int = 1
281
+ steps_batch_size: int = 1
282
+ #: :meth:`load_turns` already carries the tool payloads, so the reader
283
+ #: keeps one memo for both and parses a session once.
284
+ turns_are_complete: bool = True
285
+
286
+ def __init__(self, corpus_root: Path) -> None:
287
+ self._sessions_dir = corpus_root / "sessions"
288
+
289
+ def session_bounds(self) -> SessionBounds:
290
+ """``{session_id: (last_record_ts, trajectory_mtime)}`` over every complete dir."""
291
+ return {
292
+ d.name: (last_edge_ts(d), trajectory_mtime(d))
293
+ for d in complete_session_dirs(self._sessions_dir)
294
+ }
295
+
296
+ def batchable(self, session_id: str) -> bool:
297
+ """Never: a parse costs per session, so nothing is read ahead."""
298
+ del session_id
299
+ return False
300
+
301
+ def load_steps(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
302
+ """Parse each session's trajectory."""
303
+ return {sid: read_trajectory_steps(self._sessions_dir / sid) for sid in session_ids}
304
+
305
+ def load_turns(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
306
+ """The same as :meth:`load_steps` (a parse yields the payloads anyway)."""
307
+ return self.load_steps(session_ids)
308
+
309
+ def edges_uuids(self, session_id: str) -> set[str] | None:
310
+ """Non-null raw-record uuids from the session's ``edges.jsonl``."""
311
+ return read_edges_uuids(self._sessions_dir / session_id)
312
+
313
+
314
+ class CorpusReader:
315
+ """The pipelines' view of one corpus, over a :class:`~atif_analytics.domain.ports.SessionSource`.
316
+
317
+ Two bounded memos. The steps memo holds sessions with their tool payloads
318
+ (what the renderer reads) so the repeated reads WITHIN one session's
319
+ admission (eligibility probe, transcript render, uuid re-walk) each pay
320
+ one load. The turns memo holds the gate-level steps a source can load
321
+ without the payloads. The bound is the point: a trajectory can decode to
322
+ tens of MB, one reader is shared by every stage, and an unbounded memo
323
+ would hold the whole admitted batch resident for the run. A stage that
324
+ revisits a session evicted from the window loads it again.
325
+
326
+ Every :meth:`session_bounds` call drops the memoized sessions whose
327
+ bounds moved since the last one. The stages share one reader, and
328
+ materialize can rewrite a session between two of them, so without this a
329
+ later stage would gate on the old steps and then checkpoint the session
330
+ at its new bounds, never looking at the new ones.
331
+
332
+ Read-ahead: a source whose one call costs about the same for many
333
+ sessions as for one (the lake) sets a batch size, and a turns miss then
334
+ loads that many sessions from the miss onward in the newest-first order of
335
+ the last :meth:`session_bounds`, which is the order every gate walks. The
336
+ file source's batch size is 1, so it loads exactly what is asked for.
337
+ Only sessions the source calls :meth:`~atif_analytics.domain.ports.SessionSource.batchable`
338
+ ride along, so a session the lake source would read from its files is
339
+ parsed only when a gate asks for it.
340
+ """
341
+
342
+ def __init__(
343
+ self,
344
+ corpus_root: Path,
345
+ *,
346
+ caps: TranscriptCaps | None = None,
347
+ source: SessionSource | None = None,
348
+ steps_cache_size: int = DEFAULT_STEPS_CACHE_SIZE,
349
+ ) -> None:
350
+ self._root = corpus_root
351
+ self._caps = caps if caps is not None else TranscriptCaps()
352
+ self._source: SessionSource = (
353
+ source if source is not None else TrajectoryFileSource(corpus_root)
354
+ )
355
+ self._steps_cache: OrderedDict[str, list[StepEvent]] = OrderedDict()
356
+ self._steps_cache_size = max(1, steps_cache_size, self._source.steps_batch_size)
357
+ self._turns_cache: OrderedDict[str, list[StepEvent]] = OrderedDict()
358
+ self._turns_cache_size = 2 * max(1, self._source.turns_batch_size)
359
+ self._order: dict[str, int] = {}
360
+ self._ordered: list[str] = []
361
+ self._bounds: SessionBounds = {}
362
+
363
+ @property
364
+ def source_name(self) -> str:
365
+ """Which source this reader reads (``files`` or ``lake``)."""
366
+ return self._source.name
367
+
368
+ # ------------------------------------------------------------------
369
+ # Session enumeration
370
+ # ------------------------------------------------------------------
371
+
372
+ def session_bounds(
373
+ self, *, since_days: int | None = None, limit: int | None = None
374
+ ) -> dict[str, tuple[datetime | None, datetime | None]]:
375
+ """``{session_id: (last_record_ts, trajectory_mtime)}``, newest-first.
376
+
377
+ Drives the mtime-based checkpoint skip: either bound advancing
378
+ re-admits the session. ``since_days`` filters on the last record
379
+ timestamp; ``limit`` caps the newest-first list.
380
+
381
+ Both bounds come from cheap reads (the source never parses a
382
+ trajectory for them), because this runs once per stage per tick over
383
+ EVERY session in the window, including the ones the checkpoint is
384
+ about to skip. The order is also the read-ahead order.
385
+ """
386
+ bounds = self._source.session_bounds()
387
+ self._forget_moved(bounds)
388
+ rows = [(sid, last_ts, mtime) for sid, (last_ts, mtime) in bounds.items()]
389
+ rows.sort(key=lambda r: r[0])
390
+ if since_days is not None:
391
+ cutoff = datetime.now(UTC).timestamp() - since_days * 86_400
392
+ rows = [r for r in rows if r[1] is not None and r[1].timestamp() >= cutoff]
393
+ epoch = datetime.min.replace(tzinfo=UTC)
394
+ rows.sort(key=lambda r: (r[1] is not None, r[1] or epoch), reverse=True)
395
+ if limit is not None:
396
+ rows = rows[: int(limit)]
397
+ self._ordered = [sid for sid, _, _ in rows]
398
+ self._order = {sid: i for i, sid in enumerate(self._ordered)}
399
+ return {sid: (last_ts, mtime) for sid, last_ts, mtime in rows}
400
+
401
+ def _forget_moved(self, bounds: SessionBounds) -> None:
402
+ """Drop every memoized session whose bounds differ from the last enumeration's."""
403
+ for cache in (self._turns_cache, self._steps_cache):
404
+ for sid in [sid for sid in cache if bounds.get(sid) != self._bounds.get(sid)]:
405
+ del cache[sid]
406
+ self._bounds = bounds
407
+
408
+ def session_ids(self, *, since_days: int | None = None, limit: int | None = None) -> list[str]:
409
+ """Newest-first session ids matching the window."""
410
+ return list(self.session_bounds(since_days=since_days, limit=limit))
411
+
412
+ # ------------------------------------------------------------------
413
+ # Per-session artifacts
414
+ # ------------------------------------------------------------------
415
+
416
+ @staticmethod
417
+ def _memoize(
418
+ cache: OrderedDict[str, list[StepEvent]], size: int, session_id: str, steps: list[StepEvent]
419
+ ) -> list[StepEvent]:
420
+ """Insert into an LRU, evicting the least-recently-used entry."""
421
+ cache[session_id] = steps
422
+ cache.move_to_end(session_id)
423
+ while len(cache) > size:
424
+ cache.popitem(last=False)
425
+ return steps
426
+
427
+ def _full_steps(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
428
+ """Each session's steps with tool payloads, through the steps memo."""
429
+ out: dict[str, list[StepEvent]] = {}
430
+ missing: list[str] = []
431
+ for sid in session_ids:
432
+ cached = self._steps_cache.get(sid)
433
+ if cached is None:
434
+ missing.append(sid)
435
+ else:
436
+ self._steps_cache.move_to_end(sid)
437
+ out[sid] = cached
438
+ if missing:
439
+ loaded = self._source.load_steps(missing)
440
+ for sid in missing:
441
+ out[sid] = self._memoize(
442
+ self._steps_cache, self._steps_cache_size, sid, list(loaded.get(sid, []))
443
+ )
444
+ return out
445
+
446
+ def _read_ahead(self, session_id: str) -> list[str]:
447
+ """``session_id`` plus the next uncached sessions in the walk order, up to the batch."""
448
+ batch = [session_id]
449
+ size = self._source.turns_batch_size
450
+ start = self._order.get(session_id)
451
+ if size <= 1 or start is None:
452
+ return batch
453
+ for sid in self._ordered[start + 1 :]:
454
+ if len(batch) >= size:
455
+ break
456
+ if (
457
+ sid not in self._turns_cache
458
+ and sid not in self._steps_cache
459
+ and self._source.batchable(sid)
460
+ ):
461
+ batch.append(sid)
462
+ return batch
463
+
464
+ def load_steps(self, session_id: str) -> list[StepEvent]:
465
+ """One session's steps for the gates, memoized.
466
+
467
+ Every step with its text, flags, uuid, author and error flag; the
468
+ tool payload lists may be empty (the gates never read them, and the
469
+ lake source leaves them out). :meth:`session_text` loads the payloads.
470
+ """
471
+ if self._source.turns_are_complete:
472
+ return self._full_steps([session_id])[session_id]
473
+ for cache in (self._turns_cache, self._steps_cache):
474
+ cached = cache.get(session_id)
475
+ if cached is not None:
476
+ cache.move_to_end(session_id)
477
+ return cached
478
+ batch = self._read_ahead(session_id)
479
+ loaded = self._source.load_turns(batch)
480
+ for sid in batch:
481
+ self._memoize(self._turns_cache, self._turns_cache_size, sid, list(loaded.get(sid, [])))
482
+ return self._turns_cache[session_id]
483
+
484
+ def session_text(self, session_id: str, *, include_uuids: bool = False) -> str:
485
+ """One assembled transcript per the documented byte-shape contract."""
486
+ return self._render(self._full_steps([session_id])[session_id], include_uuids=include_uuids)
487
+
488
+ def session_texts(
489
+ self, session_ids: Sequence[str], *, include_uuids: bool = False
490
+ ) -> Iterator[str]:
491
+ """:meth:`session_text` for each session in order, loading a source batch at a time.
492
+
493
+ Holds at most one batch of loaded sessions (the steps memo is at
494
+ least that large), so rendering a capped list costs a few source
495
+ calls instead of one per session.
496
+ """
497
+ size = max(1, self._source.steps_batch_size)
498
+ for start in range(0, len(session_ids), size):
499
+ chunk = list(session_ids[start : start + size])
500
+ loaded = self._full_steps(chunk)
501
+ for sid in chunk:
502
+ yield self._render(loaded[sid], include_uuids=include_uuids)
503
+
504
+ def _render(self, steps: list[StepEvent], *, include_uuids: bool) -> str:
505
+ return render_session_text(
506
+ steps,
507
+ total_max_chars=self._caps.session_text_total_max_chars,
508
+ tool_result_max_chars=self._caps.session_text_tool_result_max_chars,
509
+ include_uuids=include_uuids,
510
+ )
511
+
512
+ def session_kind(self, session_id: str) -> SessionKind:
513
+ """``interactive`` | ``one_shot_job`` | ``turn_audit`` (atif-duck's ``session_outcomes.kind``)."""
514
+ return session_kind(self.load_steps(session_id))
515
+
516
+ def edges_uuids(self, session_id: str) -> set[str] | None:
517
+ """Non-null raw-record uuids, or ``None`` if unknown.
518
+
519
+ The conflicts pipeline validates the model's returned
520
+ ``turn_*_uuid`` values against this set. ``None`` and an empty set
521
+ are DIFFERENT answers and callers must keep them apart: ``None``
522
+ means the uuid universe is unknown, so no returned uuid can be
523
+ confirmed or refuted, while an empty set means the session
524
+ genuinely has no raw-record uuids. Neither can validate anything,
525
+ so neither may be read as "the guard passes" — a model-returned
526
+ uuid that reaches the parquet unverified is indistinguishable from
527
+ a real one to every downstream view.
528
+ """
529
+ return self._source.edges_uuids(session_id)
530
+
531
+
532
+ __all__ = [
533
+ "DEFAULT_STEPS_CACHE_SIZE",
534
+ "EDGES_FILENAME",
535
+ "META_FILENAME",
536
+ "TRAJECTORY_FILENAME",
537
+ "CorpusReader",
538
+ "TrajectoryFileSource",
539
+ "complete_session_dirs",
540
+ "last_edge_ts",
541
+ "parse_trajectory_step",
542
+ "parse_ts",
543
+ "read_edges_uuids",
544
+ "read_trajectory_steps",
545
+ "trajectory_mtime",
546
+ ]