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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|