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,151 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The port-adapter seam: atif-converter's real converters behind atif-corpus's port.
|
|
4
|
+
|
|
5
|
+
atif-corpus may never import atif-converter (import-linter independence
|
|
6
|
+
contract), so its materialize use case is written against
|
|
7
|
+
:class:`atif_corpus.domain.ports.ConverterPort`. This module — living in the
|
|
8
|
+
one package allowed to import both — adapts the real
|
|
9
|
+
:func:`atif_converter.application.convert_and_audit` (Claude Code) and
|
|
10
|
+
:func:`atif_converter.application.convert_codex.convert_codex_and_audit`
|
|
11
|
+
(Codex CLI) to that port.
|
|
12
|
+
|
|
13
|
+
ONE PORT, TWO AGENTS. The port takes a path and returns contract-shaped
|
|
14
|
+
artifacts, and that is all materialization needs to know: which agent wrote the
|
|
15
|
+
transcript changes which use case runs, not what comes back. So the agent is
|
|
16
|
+
chosen once, here, when the adapter is constructed — a corpus root holds one
|
|
17
|
+
agent's sessions by construction, so no per-session dispatch is needed and a
|
|
18
|
+
mixed pass is not a state this seam can reach.
|
|
19
|
+
|
|
20
|
+
THE AGENT ARGUMENT IS NORMALIZED BY VALUE, and that is load-bearing rather than
|
|
21
|
+
defensive. ``AgentSource`` is a deliberate TWIN: atif-converter has one copy and
|
|
22
|
+
atif-corpus has another, because the independence contract forbids either from
|
|
23
|
+
importing the other. Two enum classes with equal values are still two classes,
|
|
24
|
+
so ``settings.agent is AgentSource.CODEX`` is FALSE when ``settings`` came from
|
|
25
|
+
atif-corpus — which silently routed a Codex pass into the Claude Code converter
|
|
26
|
+
until this seam started converting the value instead of comparing the object.
|
|
27
|
+
|
|
28
|
+
Mapping decisions (ConversionResult -> ConversionOutput):
|
|
29
|
+
|
|
30
|
+
* ``trajectory_dict`` <- ``ConversionResult.trajectory`` (the ENRICHED
|
|
31
|
+
trajectory; the corpus writer owns compact serialization).
|
|
32
|
+
* ``loss_report_dict`` <- ``LossReport.to_json()`` (the contract's
|
|
33
|
+
loss_report.json shape, enum members flattened to strings).
|
|
34
|
+
* ``edges_lines`` <- ``ConversionResult.edges_lines`` (already-serialized
|
|
35
|
+
JSON lines derived from the RAW records, per the contract; the writer owns
|
|
36
|
+
line termination).
|
|
37
|
+
* ``events_lines`` <- ``ConversionResult.events_lines`` (session_events.jsonl,
|
|
38
|
+
same serialization and termination rules as the edges).
|
|
39
|
+
* ``blobs`` <- ``ConversionResult.blobs`` (the inline attachments the
|
|
40
|
+
converter replaced with placeholders), one ``BlobOutput`` per content hash.
|
|
41
|
+
* ``source_archive`` <- the files a
|
|
42
|
+
:class:`~atif_converter.infrastructure.source_archive.SourceArchiveWriter`
|
|
43
|
+
wrote into ``archive_dir`` while the use case re-verified its input, so the
|
|
44
|
+
archive holds the parsed bytes and costs no extra open of a transcript.
|
|
45
|
+
* ``EmptySessionError`` -> :class:`atif_corpus.domain.ports.EmptySourceError`:
|
|
46
|
+
the port's "nothing to convert" verdict, which materialize records instead
|
|
47
|
+
of retrying every pass.
|
|
48
|
+
|
|
49
|
+
Validation posture: a trajectory that fails post-enrichment validation
|
|
50
|
+
raises rather than materializing — the materialize use case records the
|
|
51
|
+
failure against the session and continues (its documented port contract),
|
|
52
|
+
so one invalid trajectory degrades to a per-session failure line instead of
|
|
53
|
+
a silently-wrong artifact.
|
|
54
|
+
|
|
55
|
+
NOTE: importing this module builds the ATIF pydantic models (via
|
|
56
|
+
atif_converter.application), so it must only be imported inside command bodies — never at
|
|
57
|
+
``atif_cli.app`` module top (pinned by the lean-import test).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
from typing import TYPE_CHECKING
|
|
63
|
+
|
|
64
|
+
from atif_converter.application.convert_and_audit import convert_and_audit
|
|
65
|
+
from atif_converter.application.convert_codex import convert_codex_and_audit
|
|
66
|
+
from atif_converter.domain.agents import DEFAULT_AGENT, AgentSource
|
|
67
|
+
from atif_converter.domain.errors import EmptySessionError, TrajectoryValidationError
|
|
68
|
+
from atif_converter.infrastructure.source_archive import SourceArchiveWriter
|
|
69
|
+
from atif_corpus.domain.ports import (
|
|
70
|
+
ArchivedSource,
|
|
71
|
+
BlobOutput,
|
|
72
|
+
ConversionOutput,
|
|
73
|
+
EmptySourceError,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if TYPE_CHECKING:
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RealConverter:
|
|
81
|
+
"""The production :class:`~atif_corpus.domain.ports.ConverterPort` adapter."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
include_subagents: bool = True,
|
|
87
|
+
agent: AgentSource | str = DEFAULT_AGENT,
|
|
88
|
+
) -> None:
|
|
89
|
+
#: Stage subagent side-files alongside the main chain (contract default).
|
|
90
|
+
#: Claude Code only — a Codex rollout has no side-files to stage.
|
|
91
|
+
self.include_subagents = include_subagents
|
|
92
|
+
#: Which agent wrote the transcripts this adapter will be handed. Coerced
|
|
93
|
+
#: through the enum by VALUE so atif-corpus's twin member, or a plain
|
|
94
|
+
#: string from a caller, resolves to THIS package's member — see the
|
|
95
|
+
#: module docstring.
|
|
96
|
+
self.agent = AgentSource(str(agent))
|
|
97
|
+
|
|
98
|
+
def convert(self, session_jsonl: Path, *, archive_dir: Path | None = None) -> ConversionOutput:
|
|
99
|
+
"""Convert one session through its agent's use case; raise on invalid output.
|
|
100
|
+
|
|
101
|
+
With ``archive_dir``, the session's source files are archived there as
|
|
102
|
+
a by-product of the use case's verifying re-read.
|
|
103
|
+
|
|
104
|
+
Raises
|
|
105
|
+
------
|
|
106
|
+
EmptySourceError
|
|
107
|
+
The session holds no convertible record (the converter's
|
|
108
|
+
``EmptySessionError``, translated to the port's type).
|
|
109
|
+
TrajectoryValidationError
|
|
110
|
+
When the enriched trajectory fails the ATIF validator — the
|
|
111
|
+
materialize use case records this against the session.
|
|
112
|
+
atif_converter.domain.errors.DomainError
|
|
113
|
+
For invalid sessions or adapter failures (same handling).
|
|
114
|
+
"""
|
|
115
|
+
archive = (
|
|
116
|
+
None
|
|
117
|
+
if archive_dir is None
|
|
118
|
+
else SourceArchiveWriter(archive_dir, base=session_jsonl.parent)
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
if self.agent is AgentSource.CODEX:
|
|
122
|
+
result, report = convert_codex_and_audit(session_jsonl, archive=archive)
|
|
123
|
+
else:
|
|
124
|
+
result, report = convert_and_audit(
|
|
125
|
+
session_jsonl, include_subagents=self.include_subagents, archive=archive
|
|
126
|
+
)
|
|
127
|
+
except EmptySessionError as error:
|
|
128
|
+
raise EmptySourceError(str(error)) from error
|
|
129
|
+
if result.validation_errors:
|
|
130
|
+
raise TrajectoryValidationError(list(result.validation_errors))
|
|
131
|
+
return ConversionOutput(
|
|
132
|
+
trajectory_dict=result.trajectory,
|
|
133
|
+
loss_report_dict=report.to_json(),
|
|
134
|
+
edges_lines=list(result.edges_lines),
|
|
135
|
+
events_lines=list(result.events_lines),
|
|
136
|
+
blobs=tuple(
|
|
137
|
+
BlobOutput(sha256=blob.ref.sha256, extension=blob.ref.extension, data=blob.data)
|
|
138
|
+
for blob in result.blobs
|
|
139
|
+
),
|
|
140
|
+
source_archive=(
|
|
141
|
+
()
|
|
142
|
+
if archive is None
|
|
143
|
+
else tuple(
|
|
144
|
+
ArchivedSource(relative_path=f.relative_path, size=f.size, sha256=f.sha256)
|
|
145
|
+
for f in archive.files
|
|
146
|
+
)
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["RealConverter"]
|
atif_cli/corpus.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The ``atif-sql corpus`` subcommand group: ``slim``, and the storage-layout report ``status`` prints.
|
|
4
|
+
|
|
5
|
+
materialize stores each session's ``trajectory.json``, ``edges.jsonl`` and
|
|
6
|
+
``session_events.jsonl`` zstd-compressed and no longer writes the five
|
|
7
|
+
per-session parquet files: the lake holds the queryable rows, and it loads a
|
|
8
|
+
session from its compressed trajectory. A corpus written before that keeps its
|
|
9
|
+
plain files and its parquet, and keeps working, until ``corpus slim`` converts
|
|
10
|
+
it. Nothing converts a corpus on its own: deploying this version leaves every
|
|
11
|
+
existing artifact as it is.
|
|
12
|
+
|
|
13
|
+
``corpus slim`` per corpus, in this order:
|
|
14
|
+
|
|
15
|
+
1. compress every plain artifact in place
|
|
16
|
+
(:func:`atif_corpus.infrastructure.compress_artifacts.compress_in_place`:
|
|
17
|
+
the compressed copy is read back and compared before the plain file goes,
|
|
18
|
+
and it keeps the plain file's mtime);
|
|
19
|
+
2. if any session still has parquet, verify the lake against the corpus read
|
|
20
|
+
WITHOUT the parquet (``verify_lake(use_session_parquet=False)``, the corpus
|
|
21
|
+
as it will be once they're gone);
|
|
22
|
+
3. only when that verify is clean, delete the parquet files.
|
|
23
|
+
|
|
24
|
+
A corpus the lake doesn't hold, or whose sessions differ from their lake rows
|
|
25
|
+
(a pending lake write, say), keeps its parquet; the report says why and the
|
|
26
|
+
command exits 65 or 78 after finishing every corpus. ``meta.json`` is never
|
|
27
|
+
rewritten: the lake's ``session_meta`` rows stay equal to it, and a session
|
|
28
|
+
whose parquet is gone is read from its trajectory whatever its meta says.
|
|
29
|
+
|
|
30
|
+
Dry run by default: step 1 compresses into a byte counter instead of a file,
|
|
31
|
+
so the report's numbers are exact, and steps 2 and 3 are only described.
|
|
32
|
+
|
|
33
|
+
Lean at import like :mod:`atif_cli.lake`: every heavy import is deferred into
|
|
34
|
+
the command body.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import sys
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
43
|
+
|
|
44
|
+
import cyclopts
|
|
45
|
+
|
|
46
|
+
from atif_cli.errors import EXIT_CODES, ClassifiedError
|
|
47
|
+
from atif_cli.output import OutputFormat, emit_error, emit_json, resolve_format
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from atif_duck.infrastructure.lake import LakeCorpus, LakeLayout
|
|
51
|
+
|
|
52
|
+
corpus_app = cyclopts.App(
|
|
53
|
+
name="corpus",
|
|
54
|
+
help="Maintain the materialized corpora: `slim` converts one to the compressed layout.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# The storage-layout scan (also printed by `atif-sql status`)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _size(path: Path) -> int:
|
|
64
|
+
try:
|
|
65
|
+
return path.stat().st_size
|
|
66
|
+
except OSError:
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class SessionLayout:
|
|
72
|
+
"""What one complete session still stores the old way."""
|
|
73
|
+
|
|
74
|
+
session_id: str
|
|
75
|
+
session_dir: Path
|
|
76
|
+
#: Plain artifacts that materialize now stores compressed.
|
|
77
|
+
plain: tuple[Path, ...]
|
|
78
|
+
#: Per-session parquet files present.
|
|
79
|
+
parquet: tuple[Path, ...]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class StorageLayout:
|
|
84
|
+
"""How a corpus's complete sessions are stored."""
|
|
85
|
+
|
|
86
|
+
sessions: tuple[SessionLayout, ...]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def legacy_sessions(self) -> int:
|
|
90
|
+
"""Sessions holding a plain artifact or a parquet file."""
|
|
91
|
+
return sum(1 for s in self.sessions if s.plain or s.parquet)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def plain_bytes(self) -> int:
|
|
95
|
+
"""Bytes of plain artifacts still to compress."""
|
|
96
|
+
return sum(_size(p) for s in self.sessions for p in s.plain)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def parquet_bytes(self) -> int:
|
|
100
|
+
"""Bytes of per-session parquet still on disk."""
|
|
101
|
+
return sum(_size(p) for s in self.sessions for p in s.parquet)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def summary(self) -> str:
|
|
105
|
+
"""One line for ``atif-sql status``."""
|
|
106
|
+
total = len(self.sessions)
|
|
107
|
+
if not self.legacy_sessions:
|
|
108
|
+
return f"compressed ({total} complete sessions)"
|
|
109
|
+
return (
|
|
110
|
+
f"{self.legacy_sessions} of {total} complete sessions in the old layout "
|
|
111
|
+
f"({self.plain_bytes:,} bytes of plain JSON, {self.parquet_bytes:,} bytes of "
|
|
112
|
+
"per-session parquet); `atif-sql corpus slim` converts them"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def as_dict(self) -> dict[str, Any]:
|
|
116
|
+
"""The JSON block ``atif-sql status`` emits."""
|
|
117
|
+
return {
|
|
118
|
+
"sessions": len(self.sessions),
|
|
119
|
+
"legacy_sessions": self.legacy_sessions,
|
|
120
|
+
"plain_bytes": self.plain_bytes,
|
|
121
|
+
"parquet_bytes": self.parquet_bytes,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def storage_layout(corpus_root: Path) -> StorageLayout:
|
|
126
|
+
"""Scan ``corpus_root``'s complete sessions for old-layout files (stat calls only)."""
|
|
127
|
+
from atif_corpus.infrastructure.compress_artifacts import plain_artifacts
|
|
128
|
+
from atif_duck.domain.columnar import columnar_paths
|
|
129
|
+
from atif_duck.infrastructure.lake import corpus_session_ids
|
|
130
|
+
|
|
131
|
+
sessions_dir = corpus_root / "sessions"
|
|
132
|
+
rows: list[SessionLayout] = []
|
|
133
|
+
for session_id in corpus_session_ids(corpus_root):
|
|
134
|
+
session_dir = sessions_dir / session_id
|
|
135
|
+
rows.append(
|
|
136
|
+
SessionLayout(
|
|
137
|
+
session_id=session_id,
|
|
138
|
+
session_dir=session_dir,
|
|
139
|
+
plain=tuple(plain_artifacts(session_dir)),
|
|
140
|
+
parquet=tuple(p for p in columnar_paths(session_dir) if p.is_file()),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return StorageLayout(sessions=tuple(rows))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _dir_bytes(root: Path) -> int:
|
|
147
|
+
if not root.is_dir():
|
|
148
|
+
return 0
|
|
149
|
+
return sum(_size(p) for p in root.rglob("*") if p.is_file())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# slim
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(slots=True)
|
|
158
|
+
class CorpusSlimReport:
|
|
159
|
+
"""What ``corpus slim`` did (or, on a dry run, would do) to one corpus."""
|
|
160
|
+
|
|
161
|
+
corpus: str
|
|
162
|
+
root: Path
|
|
163
|
+
sessions: int
|
|
164
|
+
bytes_before: int
|
|
165
|
+
legacy_sessions: int = 0
|
|
166
|
+
plain_files: int = 0
|
|
167
|
+
plain_bytes: int = 0
|
|
168
|
+
compressed_bytes: int = 0
|
|
169
|
+
parquet_files: int = 0
|
|
170
|
+
parquet_bytes: int = 0
|
|
171
|
+
#: ``deleted``, ``would_delete``, ``none`` (nothing to delete), or ``kept``.
|
|
172
|
+
parquet_action: str = "none"
|
|
173
|
+
#: Why the parquet was kept (or what a real run would check first).
|
|
174
|
+
parquet_note: str = ""
|
|
175
|
+
#: ``lake_mismatch`` / ``lake_unavailable`` when the parquet was kept for that reason.
|
|
176
|
+
parquet_blocked: str | None = None
|
|
177
|
+
failures: list[str] = field(default_factory=list)
|
|
178
|
+
bytes_after: int = 0
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def bytes_freed(self) -> int:
|
|
182
|
+
return self.bytes_before - self.bytes_after
|
|
183
|
+
|
|
184
|
+
def as_dict(self) -> dict[str, Any]:
|
|
185
|
+
return {
|
|
186
|
+
"corpus": self.corpus,
|
|
187
|
+
"corpus_root": str(self.root),
|
|
188
|
+
"sessions": self.sessions,
|
|
189
|
+
"legacy_sessions": self.legacy_sessions,
|
|
190
|
+
"plain_files": self.plain_files,
|
|
191
|
+
"plain_bytes": self.plain_bytes,
|
|
192
|
+
"compressed_bytes": self.compressed_bytes,
|
|
193
|
+
"parquet_files": self.parquet_files,
|
|
194
|
+
"parquet_bytes": self.parquet_bytes,
|
|
195
|
+
"parquet_action": self.parquet_action,
|
|
196
|
+
"parquet_note": self.parquet_note,
|
|
197
|
+
"failures": list(self.failures),
|
|
198
|
+
"bytes_before": self.bytes_before,
|
|
199
|
+
"bytes_after": self.bytes_after,
|
|
200
|
+
"bytes_freed": self.bytes_freed,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _lake_holds(layout: LakeLayout, corpus: LakeCorpus) -> str | None:
|
|
205
|
+
"""Why the lake can't vouch for ``corpus`` (``None`` when it holds it)."""
|
|
206
|
+
from atif_duck.infrastructure.lake import lake_status
|
|
207
|
+
|
|
208
|
+
state = lake_status(layout).as_dict()
|
|
209
|
+
if not state["present"]:
|
|
210
|
+
return f"no lake at {layout.root}; run `atif-sql lake rebuild`, then slim again"
|
|
211
|
+
if not state["schema_current"]:
|
|
212
|
+
return "the lake's schema is stale; run `atif-sql lake rebuild`, then slim again"
|
|
213
|
+
for row in state["corpora"]:
|
|
214
|
+
if row["corpus"] == corpus.name:
|
|
215
|
+
if Path(row["corpus_root"]).resolve() != corpus.root.resolve():
|
|
216
|
+
return f"the lake's corpus {corpus.name!r} is {row['corpus_root']}"
|
|
217
|
+
return None
|
|
218
|
+
return f"the lake doesn't hold {corpus.name!r}; run `atif-sql lake rebuild`, then slim again"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _slim_corpus(
|
|
222
|
+
corpus: LakeCorpus,
|
|
223
|
+
layout: LakeLayout,
|
|
224
|
+
*,
|
|
225
|
+
dry_run: bool,
|
|
226
|
+
batch_size: int,
|
|
227
|
+
stage_workers: int,
|
|
228
|
+
memory_limit_bytes: int,
|
|
229
|
+
) -> CorpusSlimReport:
|
|
230
|
+
from atif_corpus.infrastructure.compress_artifacts import (
|
|
231
|
+
CompressionMismatchError,
|
|
232
|
+
compress_in_place,
|
|
233
|
+
measure_compressed,
|
|
234
|
+
)
|
|
235
|
+
from atif_duck.infrastructure.lake import verify_lake
|
|
236
|
+
|
|
237
|
+
scan = storage_layout(corpus.root)
|
|
238
|
+
report = CorpusSlimReport(
|
|
239
|
+
corpus=corpus.name,
|
|
240
|
+
root=corpus.root,
|
|
241
|
+
sessions=len(scan.sessions),
|
|
242
|
+
bytes_before=_dir_bytes(corpus.root),
|
|
243
|
+
legacy_sessions=scan.legacy_sessions,
|
|
244
|
+
)
|
|
245
|
+
for session in scan.sessions:
|
|
246
|
+
for plain in session.plain:
|
|
247
|
+
try:
|
|
248
|
+
done = measure_compressed(plain) if dry_run else compress_in_place(plain)
|
|
249
|
+
except (OSError, CompressionMismatchError) as exc:
|
|
250
|
+
# A materialize pass that swapped the session's directory
|
|
251
|
+
# meanwhile left it in the new layout already.
|
|
252
|
+
report.failures.append(f"{session.session_id}/{plain.name}: {exc}")
|
|
253
|
+
continue
|
|
254
|
+
report.plain_files += 1
|
|
255
|
+
report.plain_bytes += done.plain_bytes
|
|
256
|
+
report.compressed_bytes += done.compressed_bytes
|
|
257
|
+
parquet = [path for session in scan.sessions for path in session.parquet]
|
|
258
|
+
report.parquet_files = len(parquet)
|
|
259
|
+
report.parquet_bytes = sum(_size(path) for path in parquet)
|
|
260
|
+
|
|
261
|
+
blocked = _lake_holds(layout, corpus) if parquet else None
|
|
262
|
+
if not parquet:
|
|
263
|
+
report.parquet_action = "none"
|
|
264
|
+
elif blocked is not None:
|
|
265
|
+
report.parquet_action = "kept"
|
|
266
|
+
report.parquet_note = blocked
|
|
267
|
+
report.parquet_blocked = "lake_unavailable"
|
|
268
|
+
elif dry_run:
|
|
269
|
+
report.parquet_action = "would_delete"
|
|
270
|
+
report.parquet_note = (
|
|
271
|
+
"a real run deletes them once `lake verify`, reading the corpus without "
|
|
272
|
+
"them, comes back clean"
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
verified = verify_lake(
|
|
276
|
+
layout,
|
|
277
|
+
[corpus],
|
|
278
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
279
|
+
use_session_parquet=False,
|
|
280
|
+
batch_size=batch_size,
|
|
281
|
+
stage_workers=stage_workers,
|
|
282
|
+
)
|
|
283
|
+
if verified.clean:
|
|
284
|
+
for path in parquet:
|
|
285
|
+
path.unlink(missing_ok=True)
|
|
286
|
+
report.parquet_action = "deleted"
|
|
287
|
+
report.parquet_note = "lake verify without them was clean"
|
|
288
|
+
else:
|
|
289
|
+
differing = sorted({m.session_id for m in verified.mismatches})
|
|
290
|
+
report.parquet_action = "kept"
|
|
291
|
+
report.parquet_blocked = "lake_mismatch" if not verified.stale else "lake_unavailable"
|
|
292
|
+
report.parquet_note = (
|
|
293
|
+
f"{len(differing)} session(s) differ from their lake rows "
|
|
294
|
+
f"(first: {', '.join(differing[:3])}); run `atif-sql materialize` to "
|
|
295
|
+
"retry pending lake writes, or `atif-sql lake rebuild`, then slim again"
|
|
296
|
+
if not verified.stale
|
|
297
|
+
else f"the lake can't be verified: {', '.join(verified.stale)}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if dry_run:
|
|
301
|
+
freed_parquet = report.parquet_bytes if report.parquet_action == "would_delete" else 0
|
|
302
|
+
report.bytes_after = (
|
|
303
|
+
report.bytes_before - report.plain_bytes + report.compressed_bytes - freed_parquet
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
report.bytes_after = _dir_bytes(corpus.root)
|
|
307
|
+
return report
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@corpus_app.command
|
|
311
|
+
def slim(
|
|
312
|
+
*,
|
|
313
|
+
corpus_root: Annotated[list[Path] | None, cyclopts.Parameter(consume_multiple=False)] = None,
|
|
314
|
+
lake_root: Path | None = None,
|
|
315
|
+
dry_run: Annotated[bool, cyclopts.Parameter(negative="--no-dry-run")] = True,
|
|
316
|
+
fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Convert corpora to the compressed layout; a dry run (the default) only reports.
|
|
319
|
+
|
|
320
|
+
Compresses every plain ``trajectory.json``, ``edges.jsonl`` and
|
|
321
|
+
``session_events.jsonl`` in place (each read back and compared before the
|
|
322
|
+
plain file is removed), then deletes the per-session parquet once ``lake
|
|
323
|
+
verify``, reading the corpus without it, is clean. Reports the bytes each
|
|
324
|
+
corpus frees. Exits 0 when every corpus is done, 65 (``lake_mismatch``)
|
|
325
|
+
or 78 (``lake_unavailable``) when a corpus kept its parquet for that
|
|
326
|
+
reason, 70 when a file could not be compressed.
|
|
327
|
+
|
|
328
|
+
Run it while no materialize pass is running: a pass that replaces a
|
|
329
|
+
session mid-slim leaves that session in the new layout anyway, but the
|
|
330
|
+
lake verify may then report it as differing.
|
|
331
|
+
|
|
332
|
+
Parameters
|
|
333
|
+
----------
|
|
334
|
+
corpus_root
|
|
335
|
+
A corpus to slim; repeat for more. Default: every corpus the lake
|
|
336
|
+
holds, plus every directory under ``ATIF_SQL_CORPUS_BASE`` (default
|
|
337
|
+
``~/.atif-sql/corpus``) that holds ``sessions/``.
|
|
338
|
+
lake_root
|
|
339
|
+
The lake that vouches for the corpora (default ``ATIF_SQL_LAKE_ROOT``).
|
|
340
|
+
dry_run
|
|
341
|
+
Report what would change (exact byte counts) and change nothing.
|
|
342
|
+
``--no-dry-run`` acts.
|
|
343
|
+
fmt
|
|
344
|
+
``auto`` = human lines on a TTY, JSON on a pipe.
|
|
345
|
+
"""
|
|
346
|
+
from atif_cli.lake import discover_corpora, lake_layout, writer_memory_limit
|
|
347
|
+
|
|
348
|
+
layout, settings = lake_layout(lake_root)
|
|
349
|
+
corpora = discover_corpora(layout, settings, corpus_root)
|
|
350
|
+
if not corpora:
|
|
351
|
+
err = ClassifiedError(
|
|
352
|
+
kind="invalid_input",
|
|
353
|
+
exit_code=EXIT_CODES["invalid_input"],
|
|
354
|
+
message="no corpus to slim",
|
|
355
|
+
hint="pass --corpus-root",
|
|
356
|
+
)
|
|
357
|
+
emit_error(err, fmt)
|
|
358
|
+
raise SystemExit(err.exit_code)
|
|
359
|
+
memory_limit = writer_memory_limit()
|
|
360
|
+
reports = [
|
|
361
|
+
_slim_corpus(
|
|
362
|
+
corpus,
|
|
363
|
+
layout,
|
|
364
|
+
dry_run=dry_run,
|
|
365
|
+
batch_size=settings.lake_load_batch_size,
|
|
366
|
+
stage_workers=settings.lake_stage_workers,
|
|
367
|
+
memory_limit_bytes=memory_limit,
|
|
368
|
+
)
|
|
369
|
+
for corpus in corpora
|
|
370
|
+
]
|
|
371
|
+
payload = {
|
|
372
|
+
"dry_run": dry_run,
|
|
373
|
+
"lake_root": str(layout.root),
|
|
374
|
+
"corpora": [r.as_dict() for r in reports],
|
|
375
|
+
"bytes_before": sum(r.bytes_before for r in reports),
|
|
376
|
+
"bytes_after": sum(r.bytes_after for r in reports),
|
|
377
|
+
"bytes_freed": sum(r.bytes_freed for r in reports),
|
|
378
|
+
}
|
|
379
|
+
if resolve_format(fmt) is OutputFormat.TABLE:
|
|
380
|
+
verb = "would free" if dry_run else "freed"
|
|
381
|
+
for r in reports:
|
|
382
|
+
print(
|
|
383
|
+
f"{r.corpus}: {r.legacy_sessions} of {r.sessions} sessions in the old layout; "
|
|
384
|
+
f"{r.plain_files} plain file(s) {r.plain_bytes:,} -> {r.compressed_bytes:,} bytes; "
|
|
385
|
+
f"{r.parquet_files} parquet file(s) {r.parquet_bytes:,} bytes {r.parquet_action}; "
|
|
386
|
+
f"{verb} {r.bytes_freed:,} bytes ({r.bytes_before:,} -> {r.bytes_after:,})"
|
|
387
|
+
)
|
|
388
|
+
if r.parquet_note:
|
|
389
|
+
print(f" parquet: {r.parquet_note}")
|
|
390
|
+
for failure in r.failures:
|
|
391
|
+
print(f" FAILED {failure}", file=sys.stderr)
|
|
392
|
+
total = payload["bytes_freed"]
|
|
393
|
+
print(
|
|
394
|
+
f"total: {verb} {total:,} bytes" + (" (dry run; --no-dry-run acts)" if dry_run else "")
|
|
395
|
+
)
|
|
396
|
+
else:
|
|
397
|
+
emit_json(payload, fmt)
|
|
398
|
+
if dry_run:
|
|
399
|
+
return
|
|
400
|
+
if any(r.failures for r in reports):
|
|
401
|
+
raise SystemExit(EXIT_CODES["runtime_error"])
|
|
402
|
+
blocked = [r.parquet_blocked for r in reports if r.parquet_blocked]
|
|
403
|
+
if "lake_mismatch" in blocked:
|
|
404
|
+
raise SystemExit(EXIT_CODES["lake_mismatch"])
|
|
405
|
+
if blocked:
|
|
406
|
+
raise SystemExit(EXIT_CODES["lake_unavailable"])
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
__all__ = ["StorageLayout", "corpus_app", "storage_layout"]
|