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,266 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Atomic file writes: tmp-in-same-dir + fsync + rename, never a partial artifact.
4
+
5
+ atif-duck reads the corpus with no coordination — no locks, no journal — so
6
+ the ONLY correctness lever the writer has is that every artifact path either
7
+ holds a complete previous version or a complete new version. ``os.replace``
8
+ of a sibling tmp file gives exactly that on POSIX (same filesystem, atomic
9
+ rename); a failure mid-serialization leaves the destination untouched and
10
+ the tmp file removed.
11
+
12
+ Durability, not just visibility: write ORDER is not persistence order across
13
+ a kernel crash or power loss — a journalling filesystem can replay the
14
+ renames while the data blocks are still dirty, publishing a ``meta.json``
15
+ (which atif-duck treats as "this session is complete") next to a
16
+ zero-length ``trajectory.json``. Every writer here therefore fsyncs the tmp
17
+ file BEFORE the rename, and the directory holding the renamed entries after,
18
+ so persistence order matches write order. The corpus is regenerable, so this
19
+ is cheap insurance rather than a hot-path cost.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import shutil
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ from loguru import logger
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Callable, Iterable, Iterator
33
+ from pathlib import Path
34
+
35
+ #: Compact separators for trajectory.json, fixed by CONTRACT.md.
36
+ COMPACT_SEPARATORS: tuple[str, str] = (",", ":")
37
+
38
+ #: zstd level for the compressed artifacts. The library default: on the
39
+ #: copied corpora it stores the JSON artifacts at about a tenth of their size
40
+ #: at a few hundred MB/s on one core, and the level is part of what makes a
41
+ #: pool worker and ``--workers 1`` write the same bytes, so it is pinned here
42
+ #: rather than left to the library.
43
+ ZSTD_LEVEL: int = 3
44
+
45
+
46
+ def _tmp_sibling(path: Path) -> Path:
47
+ """A tmp path in the SAME directory (rename across filesystems isn't atomic)."""
48
+ return path.with_name(f".{path.name}.tmp-{os.getpid()}")
49
+
50
+
51
+ def fsync_dir(path: Path) -> None:
52
+ """Flush ``path``'s directory entries so renames into it survive power loss.
53
+
54
+ A rename is metadata: without this the entry can be lost even though the
55
+ file it names was fsynced. Best-effort — a filesystem that refuses
56
+ ``O_RDONLY`` fsync on a directory must not fail a materialization pass
57
+ over a durability upgrade.
58
+ """
59
+ try:
60
+ fd = os.open(path, os.O_RDONLY)
61
+ except OSError as error:
62
+ logger.debug("fsync_dir: cannot open {} ({}); skipping", path, error)
63
+ return
64
+ try:
65
+ os.fsync(fd)
66
+ except OSError as error:
67
+ logger.debug("fsync_dir: fsync of {} failed ({}); skipping", path, error)
68
+ finally:
69
+ os.close(fd)
70
+
71
+
72
+ def write_json_atomic(
73
+ path: Path,
74
+ obj: Any,
75
+ *,
76
+ compact: bool = False,
77
+ indent: int | None = None,
78
+ ) -> None:
79
+ """Serialize ``obj`` as JSON to ``path`` atomically and durably.
80
+
81
+ Streams ``json.dump`` into the tmp file — an object that fails to
82
+ serialize partway (the mid-write failure the tests pin) leaves a partial
83
+ TMP file, which is unlinked, and never a partial destination. The tmp
84
+ file is fsynced before the rename so its bytes are on stable storage
85
+ before the name that publishes them, and the parent directory after so
86
+ the rename itself survives power loss.
87
+ """
88
+ tmp = _tmp_sibling(path)
89
+ try:
90
+ with tmp.open("w", encoding="utf-8") as handle:
91
+ if compact:
92
+ json.dump(obj, handle, separators=COMPACT_SEPARATORS)
93
+ else:
94
+ json.dump(obj, handle, indent=indent)
95
+ handle.write("\n")
96
+ handle.flush()
97
+ os.fsync(handle.fileno())
98
+ tmp.replace(path)
99
+ except BaseException:
100
+ logger.debug("atomic write failed; removing tmp {}", tmp)
101
+ tmp.unlink(missing_ok=True)
102
+ raise
103
+ fsync_dir(path.parent)
104
+
105
+
106
+ def _json_chunks(obj: Any, *, compact: bool) -> Iterator[str]:
107
+ """``obj`` serialized in pieces, exactly as :func:`write_json_atomic` serializes it."""
108
+ encoder = json.JSONEncoder(separators=COMPACT_SEPARATORS if compact else None)
109
+ yield from encoder.iterencode(obj)
110
+ yield "\n"
111
+
112
+
113
+ def _zstd_compressor() -> Any:
114
+ import zstandard
115
+
116
+ return zstandard.ZstdCompressor(level=ZSTD_LEVEL, write_content_size=True, threads=0)
117
+
118
+
119
+ def _write_zstd_chunks(path: Path, chunks: Callable[[], Iterable[bytes]], size: int) -> None:
120
+ """Compress ``chunks()`` (``size`` bytes in all) into ``path`` atomically and durably.
121
+
122
+ The frame records ``size`` as its content size, so a reader learns the
123
+ document's size from the first bytes of the file. Same tmp, fsync, rename,
124
+ fsync-dir discipline as :func:`write_json_atomic`.
125
+ """
126
+ tmp = _tmp_sibling(path)
127
+ try:
128
+ with tmp.open("wb") as handle:
129
+ with _zstd_compressor().stream_writer(handle, size=size, closefd=False) as writer:
130
+ for chunk in chunks():
131
+ writer.write(chunk)
132
+ handle.flush()
133
+ os.fsync(handle.fileno())
134
+ tmp.replace(path)
135
+ except BaseException:
136
+ logger.debug("atomic write failed; removing tmp {}", tmp)
137
+ tmp.unlink(missing_ok=True)
138
+ raise
139
+ fsync_dir(path.parent)
140
+
141
+
142
+ def write_json_zstd_atomic(path: Path, obj: Any, *, compact: bool = False) -> None:
143
+ """Write what :func:`write_json_atomic` would write, zstd-compressed, to ``path``.
144
+
145
+ Serializes twice: once to count the bytes (the frame header carries the
146
+ content size, which readers size their JSON parser from) and once into
147
+ the compressor, so no serialized copy of a large document is ever held in
148
+ memory. Decompressed, the file is byte for byte the plain artifact.
149
+ """
150
+
151
+ def chunks() -> Iterator[bytes]:
152
+ for piece in _json_chunks(obj, compact=compact):
153
+ yield piece.encode("utf-8")
154
+
155
+ size = sum(len(chunk) for chunk in chunks())
156
+ _write_zstd_chunks(path, chunks, size)
157
+
158
+
159
+ def write_text_zstd_atomic(path: Path, text: str) -> None:
160
+ """Write ``text`` zstd-compressed to ``path`` atomically and durably (see :func:`write_text_atomic`)."""
161
+ data = text.encode("utf-8")
162
+ _write_zstd_chunks(path, lambda: (data,), len(data))
163
+
164
+
165
+ def replace_dir_atomic(tmp_dir: Path, dst_dir: Path) -> None:
166
+ """Swap a fully-written ``tmp_dir`` into place at ``dst_dir``.
167
+
168
+ The directory rename is the atomicity boundary for an artifact SET: a
169
+ reader either sees the complete previous generation at ``dst_dir`` or
170
+ the complete new one, never a mix. ``tmp_dir`` must be on the same
171
+ filesystem AND must live OUTSIDE any directory readers glob (DuckDB's
172
+ ``read_json`` glob matches dot-dirs, verified on 1.5) — atif-corpus
173
+ stages under ``<corpus_root>/.staging/``, which no reader glob touches.
174
+
175
+ ``os.replace`` cannot overwrite a non-empty directory, so an existing
176
+ ``dst_dir`` is first renamed aside NEXT TO ``tmp_dir`` (outside the
177
+ globbed tree) and removed after the swap; an EXCEPTION between the two
178
+ renames triggers the restore below.
179
+
180
+ What this does NOT survive: a SIGKILL or power loss in the window
181
+ between the aside-rename and the swap leaves NO generation at
182
+ ``dst_dir`` — no in-process handler can run there, and POSIX offers no
183
+ atomic directory exchange (``renameat2(RENAME_EXCHANGE)`` is
184
+ Linux-specific and unexposed). Recovery is owned by the next pass, not
185
+ by this function:
186
+ :func:`atif_corpus.application.materialize.materialize` sweeps
187
+ ``.staging/`` at pass start and force-replans any session the watermark
188
+ claims is current but whose dir is absent. A reader that catches the
189
+ window sees a missing session, never a torn one.
190
+ """
191
+ old = tmp_dir.with_name(f"{tmp_dir.name}.old-{os.getpid()}")
192
+ shutil.rmtree(old, ignore_errors=True)
193
+ fsync_dir(tmp_dir)
194
+ if dst_dir.is_dir():
195
+ dst_dir.rename(old)
196
+ try:
197
+ tmp_dir.replace(dst_dir)
198
+ except BaseException:
199
+ logger.debug("dir swap failed; restoring previous generation at {}", dst_dir)
200
+ if old.is_dir() and not dst_dir.exists():
201
+ old.rename(dst_dir)
202
+ raise
203
+ fsync_dir(dst_dir.parent)
204
+ shutil.rmtree(old, ignore_errors=True)
205
+
206
+
207
+ #: Stored blobs are read-only, like the columnar artifacts: nothing rewrites
208
+ #: a content-addressed file in place.
209
+ _BLOB_MODE: int = 0o444
210
+
211
+
212
+ def write_blob_atomic(path: Path, data: bytes) -> bool:
213
+ """Store a content-addressed blob at ``path`` unless it is already there.
214
+
215
+ Returns ``True`` when this call wrote the file, ``False`` when a file of
216
+ the same size already held the name (the name IS the content hash, so a
217
+ same-size file under it is the same bytes, written by an earlier session
218
+ or a concurrent worker). A file of another size under the name is a torn
219
+ or foreign file and is replaced.
220
+
221
+ Same discipline as :func:`write_json_atomic`: a pid-suffixed tmp sibling,
222
+ fsynced before the rename that publishes it, the directory fsynced after.
223
+ Two workers storing the same blob at once each rename a complete file of
224
+ identical bytes into place, so neither can expose a partial one.
225
+ """
226
+ try:
227
+ if path.stat().st_size == len(data):
228
+ return False
229
+ except FileNotFoundError:
230
+ pass
231
+ path.parent.mkdir(parents=True, exist_ok=True)
232
+ tmp = _tmp_sibling(path)
233
+ try:
234
+ with tmp.open("wb") as handle:
235
+ handle.write(data)
236
+ handle.flush()
237
+ os.fsync(handle.fileno())
238
+ tmp.chmod(_BLOB_MODE)
239
+ tmp.replace(path)
240
+ except BaseException:
241
+ logger.debug("blob write failed; removing tmp {}", tmp)
242
+ tmp.unlink(missing_ok=True)
243
+ raise
244
+ fsync_dir(path.parent)
245
+ return True
246
+
247
+
248
+ def write_text_atomic(path: Path, text: str) -> None:
249
+ """Write ``text`` to ``path`` atomically and durably.
250
+
251
+ Same discipline as :func:`write_json_atomic`: tmp sibling, fsync the data
252
+ before the rename that publishes it, fsync the parent directory after so
253
+ the rename survives power loss.
254
+ """
255
+ tmp = _tmp_sibling(path)
256
+ try:
257
+ with tmp.open("w", encoding="utf-8") as handle:
258
+ handle.write(text)
259
+ handle.flush()
260
+ os.fsync(handle.fileno())
261
+ tmp.replace(path)
262
+ except BaseException:
263
+ logger.debug("atomic write failed; removing tmp {}", tmp)
264
+ tmp.unlink(missing_ok=True)
265
+ raise
266
+ fsync_dir(path.parent)
@@ -0,0 +1,161 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Compress an existing session's plain JSON artifacts in place (``atif-sql corpus slim``).
4
+
5
+ A session written before materialize stored its JSON artifacts compressed
6
+ holds ``trajectory.json``, ``edges.jsonl`` and ``session_events.jsonl`` as
7
+ plain files. :func:`compress_in_place` turns one of them into ``<name>.zst``
8
+ without a moment where the session lacks a readable copy:
9
+
10
+ 1. stream the plain file through zstd into a pid-suffixed tmp sibling, the
11
+ frame recording the plain size as its content size (what the materialize
12
+ writer records too), then fsync it;
13
+ 2. read the tmp file back, decompressing, and compare its sha256 with the
14
+ plain file's; a difference unlinks the tmp file and raises, leaving the
15
+ plain file as it was;
16
+ 3. give the tmp file the plain file's mtime, then rename it to
17
+ ``<name>.zst`` and fsync the directory;
18
+ 4. unlink the plain file and fsync the directory again.
19
+
20
+ Between 3 and 4 the session holds both spellings with the same content, and
21
+ every reader prefers the compressed one. The mtime is carried over because
22
+ analyze's checkpoints bound each session by its trajectory's mtime: a
23
+ compressed copy with a new mtime would make every session look rewritten and
24
+ re-run every pipeline on it.
25
+
26
+ The same compressor settings as the materialize writer
27
+ (:data:`atif_corpus.infrastructure.atomic.ZSTD_LEVEL`, single-threaded), so a
28
+ slimmed session and a freshly converted one store the same bytes for the same
29
+ document.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import os
36
+ from dataclasses import dataclass
37
+ from typing import TYPE_CHECKING
38
+
39
+ from loguru import logger
40
+
41
+ from atif_corpus.domain.layout import COMPRESSED_ARTIFACT_FILENAMES, COMPRESSED_SUFFIX
42
+ from atif_corpus.infrastructure.atomic import ZSTD_LEVEL, fsync_dir
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Iterator
46
+ from pathlib import Path
47
+
48
+ #: Read and write chunk size.
49
+ _CHUNK_BYTES: int = 1024 * 1024
50
+
51
+
52
+ class CompressionMismatchError(RuntimeError):
53
+ """The compressed copy did not decompress to the plain file's bytes; nothing was replaced."""
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Compressed:
58
+ """One artifact compressed (or, on a dry run, measured)."""
59
+
60
+ #: The plain file (removed unless this was a dry run).
61
+ plain: Path
62
+ #: Its size.
63
+ plain_bytes: int
64
+ #: The compressed file's size (on a dry run, what it would be).
65
+ compressed_bytes: int
66
+
67
+
68
+ def plain_artifacts(session_dir: Path) -> list[Path]:
69
+ """The session's artifacts still stored plain, in :data:`COMPRESSED_ARTIFACT_FILENAMES` order."""
70
+ return [
71
+ session_dir / name
72
+ for name in COMPRESSED_ARTIFACT_FILENAMES
73
+ if (session_dir / name).is_file()
74
+ ]
75
+
76
+
77
+ def _compressed_chunks(plain: Path, size: int) -> Iterator[bytes]:
78
+ """``plain``'s bytes as one zstd frame, in pieces (a dry run counts them, a real run writes them)."""
79
+ import zstandard
80
+
81
+ compressor = zstandard.ZstdCompressor(level=ZSTD_LEVEL, write_content_size=True, threads=0)
82
+ stream = compressor.compressobj(size=size)
83
+ with plain.open("rb") as source:
84
+ while chunk := source.read(_CHUNK_BYTES):
85
+ if out := stream.compress(chunk):
86
+ yield out
87
+ yield stream.flush()
88
+
89
+
90
+ def _sha256_plain(path: Path) -> str:
91
+ digest = hashlib.sha256()
92
+ with path.open("rb") as handle:
93
+ while chunk := handle.read(_CHUNK_BYTES):
94
+ digest.update(chunk)
95
+ return digest.hexdigest()
96
+
97
+
98
+ def _sha256_decompressed(path: Path) -> str:
99
+ import zstandard
100
+
101
+ digest = hashlib.sha256()
102
+ with path.open("rb") as handle, zstandard.ZstdDecompressor().stream_reader(handle) as reader:
103
+ while chunk := reader.read(_CHUNK_BYTES):
104
+ digest.update(chunk)
105
+ return digest.hexdigest()
106
+
107
+
108
+ def _check_round_trip(compressed: Path, plain: Path) -> None:
109
+ if _sha256_decompressed(compressed) != _sha256_plain(plain):
110
+ msg = f"{compressed} does not decompress to {plain}; left {plain} as it was"
111
+ raise CompressionMismatchError(msg)
112
+
113
+
114
+ def measure_compressed(plain: Path) -> Compressed:
115
+ """What :func:`compress_in_place` would store for ``plain``, without writing anything."""
116
+ size = plain.stat().st_size
117
+ compressed = sum(len(chunk) for chunk in _compressed_chunks(plain, size))
118
+ return Compressed(plain=plain, plain_bytes=size, compressed_bytes=compressed)
119
+
120
+
121
+ def compress_in_place(plain: Path) -> Compressed:
122
+ """Replace ``plain`` with ``<plain>.zst`` holding the same bytes (see the module docstring).
123
+
124
+ Raises
125
+ ------
126
+ CompressionMismatchError
127
+ The compressed copy read back differently; ``plain`` is untouched.
128
+ OSError
129
+ The file or its directory went away (a materialize pass swapped the
130
+ session's directory meanwhile) or could not be written.
131
+ """
132
+ stat = plain.stat()
133
+ target = plain.with_name(f"{plain.name}{COMPRESSED_SUFFIX}")
134
+ tmp = plain.with_name(f".{target.name}.tmp-{os.getpid()}")
135
+ try:
136
+ with tmp.open("wb") as handle:
137
+ for chunk in _compressed_chunks(plain, stat.st_size):
138
+ handle.write(chunk)
139
+ handle.flush()
140
+ os.fsync(handle.fileno())
141
+ _check_round_trip(tmp, plain)
142
+ os.utime(tmp, ns=(stat.st_atime_ns, stat.st_mtime_ns))
143
+ compressed_bytes = tmp.stat().st_size
144
+ tmp.replace(target)
145
+ except BaseException:
146
+ tmp.unlink(missing_ok=True)
147
+ raise
148
+ fsync_dir(plain.parent)
149
+ plain.unlink()
150
+ fsync_dir(plain.parent)
151
+ logger.debug("slim: {} -> {} ({} -> {} bytes)", plain, target, stat.st_size, compressed_bytes)
152
+ return Compressed(plain=plain, plain_bytes=stat.st_size, compressed_bytes=compressed_bytes)
153
+
154
+
155
+ __all__ = [
156
+ "Compressed",
157
+ "CompressionMismatchError",
158
+ "compress_in_place",
159
+ "measure_compressed",
160
+ "plain_artifacts",
161
+ ]
@@ -0,0 +1,174 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Test adapter for :class:`atif_corpus.domain.ports.ConverterPort`.
4
+
5
+ Lives in ``infrastructure`` (not ``tests/``) on purpose: atif-cli's tests
6
+ and future integration harnesses need the same fake, and a fake that ships
7
+ with the package is type-checked against the port on every ``ty`` run
8
+ instead of drifting in a conftest.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import time
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ import zstandard
20
+
21
+ from atif_corpus.domain.ports import ArchivedSource, ConversionOutput, EmptySourceError
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+
27
+ def _archive_sources(session_jsonl: Path, archive_dir: Path) -> tuple[ArchivedSource, ...]:
28
+ """Write a zstd copy of the main file and every file under its side dir.
29
+
30
+ The fake reads the files itself; the real adapter takes the bytes from the
31
+ converter's verifying read instead (see ``atif_converter.infrastructure
32
+ .source_archive``). The on-disk shape is the same, which is all the use
33
+ case sees.
34
+ """
35
+ base = session_jsonl.parent
36
+ side_dir = base / session_jsonl.stem
37
+ paths = [session_jsonl]
38
+ if side_dir.is_dir():
39
+ paths.extend(p for p in sorted(side_dir.rglob("*")) if p.is_file() and not p.is_symlink())
40
+ archived: list[ArchivedSource] = []
41
+ for path in paths:
42
+ data = path.read_bytes()
43
+ relative = path.relative_to(base).as_posix()
44
+ target = archive_dir / f"{relative}.zst"
45
+ target.parent.mkdir(parents=True, exist_ok=True)
46
+ target.write_bytes(zstandard.ZstdCompressor(level=3).compress(data))
47
+ archived.append(
48
+ ArchivedSource(
49
+ relative_path=relative, size=len(data), sha256=hashlib.sha256(data).hexdigest()
50
+ )
51
+ )
52
+ return tuple(archived)
53
+
54
+
55
+ #: This process's simulated clock, in seconds. Only :class:`FakeConverter`
56
+ #: with ``simulated_seconds`` moves it, and only inside ``convert``.
57
+ _simulated_now: float = 0.0
58
+
59
+
60
+ def simulated_clock() -> float:
61
+ """A clock that advances only while a :class:`FakeConverter` converts.
62
+
63
+ Hand it to ``materialize(convert_clock=...)`` with
64
+ ``FakeConverter(simulated_seconds=s)`` and every conversion measures
65
+ exactly ``s``, in whichever process (inline or pool worker) runs it,
66
+ however long the pass really takes. Module-level, so it pickles into a
67
+ spawned worker by reference; each process keeps its own count.
68
+ """
69
+ return _simulated_now
70
+
71
+
72
+ class FakeConverter:
73
+ """A deterministic in-memory converter with scriptable failures.
74
+
75
+ ``convert`` fabricates a minimal-but-contract-shaped output keyed by the
76
+ session stem, records every call in ``converted``, and raises for any
77
+ session listed in ``fail_sessions`` — which is how the "a failing
78
+ session is recorded and skipped" behavior gets exercised.
79
+
80
+ Two knobs exist for the process-pool tests. ``record_pid`` stamps the
81
+ converting process's pid into the trajectory as ``worker_pid``, so a test
82
+ can read back from disk WHICH process converted each session — a pool
83
+ that quietly ran inline shows the parent's pid on every one.
84
+ ``delay_seconds`` holds each conversion open long enough for the pool's
85
+ other workers to start and take a session of their own; without it one
86
+ fast worker can drain a small fixture before the second exists. Both are
87
+ off by default, and ``converted`` is only meaningful on the inline path:
88
+ a pool worker's copy of this object never comes back.
89
+
90
+ ``simulated_seconds`` advances :func:`simulated_clock` by that much on
91
+ every conversion, so a pass timed with that clock reports a known
92
+ duration per session.
93
+
94
+ ``empty_sessions`` raises :class:`~atif_corpus.domain.ports.EmptySourceError`
95
+ for the named sessions, the port's "nothing to convert" verdict.
96
+ ``output_tag`` stamps ``converter_tag`` into every trajectory so a test can
97
+ read back which converter build wrote a session. Handed an
98
+ ``archive_dir``, the fake writes a real zstd archive of the session's files.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ fail_sessions: frozenset[str] = frozenset(),
105
+ empty_sessions: frozenset[str] = frozenset(),
106
+ record_pid: bool = False,
107
+ delay_seconds: float = 0.0,
108
+ simulated_seconds: float = 0.0,
109
+ output_tag: str | None = None,
110
+ ) -> None:
111
+ #: Session ids whose conversion should raise.
112
+ self.fail_sessions = fail_sessions
113
+ #: Session ids whose conversion raises :class:`EmptySourceError`.
114
+ self.empty_sessions = empty_sessions
115
+ #: Stamped into the trajectory as ``converter_tag``, so a test can see
116
+ #: which converter wrote the artifacts on disk (a version bump's re-run).
117
+ self.output_tag = output_tag
118
+ #: Stamp ``os.getpid()`` into the trajectory as ``worker_pid``.
119
+ self.record_pid = record_pid
120
+ #: Seconds each conversion sleeps before returning.
121
+ self.delay_seconds = delay_seconds
122
+ #: Seconds each conversion advances :func:`simulated_clock` by.
123
+ self.simulated_seconds = simulated_seconds
124
+ #: Every session path convert() was asked about, in call order.
125
+ self.converted: list[Path] = []
126
+
127
+ def convert(self, session_jsonl: Path, *, archive_dir: Path | None = None) -> ConversionOutput:
128
+ """Fabricate contract-shaped artifacts for ``session_jsonl``."""
129
+ session_id = session_jsonl.stem
130
+ self.converted.append(session_jsonl)
131
+ if session_id in self.fail_sessions:
132
+ msg = f"scripted failure for {session_id}"
133
+ raise RuntimeError(msg)
134
+ if session_id in self.empty_sessions:
135
+ msg = f"no convertible events in {session_jsonl}"
136
+ raise EmptySourceError(msg)
137
+ if self.delay_seconds:
138
+ time.sleep(self.delay_seconds)
139
+ if self.simulated_seconds:
140
+ global _simulated_now # noqa: PLW0603 — the clock is per process by design
141
+ _simulated_now += self.simulated_seconds
142
+ trajectory: dict[str, Any] = {
143
+ "schema_version": "ATIF-v1.7",
144
+ "session_id": session_id,
145
+ "steps": [],
146
+ }
147
+ if self.record_pid:
148
+ trajectory["worker_pid"] = os.getpid()
149
+ if self.output_tag is not None:
150
+ trajectory["converter_tag"] = self.output_tag
151
+ loss_report: dict[str, Any] = {
152
+ "records_converted": 0,
153
+ "records_dropped": 0,
154
+ "gaps_observed": [],
155
+ }
156
+ edge: dict[str, Any] = {
157
+ "uuid": f"{session_id}-u1",
158
+ "parent_uuid": None,
159
+ "message_id": None,
160
+ "type": "user",
161
+ "ts": "2026-01-01T00:00:00Z",
162
+ "is_sidechain": False,
163
+ "is_compact_summary": False,
164
+ "source_file": str(session_jsonl),
165
+ "tool_use_ids": [],
166
+ }
167
+ return ConversionOutput(
168
+ trajectory_dict=trajectory,
169
+ loss_report_dict=loss_report,
170
+ edges_lines=[json.dumps(edge, separators=(",", ":"))],
171
+ source_archive=(
172
+ () if archive_dir is None else _archive_sources(session_jsonl, archive_dir)
173
+ ),
174
+ )