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,9 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Pure domain layer for corpus materialization.
4
+
5
+ Value objects and pure functions only: no filesystem, no env, no clock.
6
+ Every timestamp is passed IN (epoch nanoseconds from ``os.stat().st_mtime_ns``
7
+ or ``time.time_ns()``) so the materialization decision is a deterministic
8
+ function of its inputs — the property the plan-determinism tests pin.
9
+ """
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Which agent produced a transcript — atif-corpus's copy of the one enum.
4
+
5
+ DELIBERATE TWIN of :mod:`atif_converter.domain.agents`, and the same kind of
6
+ twin as ``sql_literal`` and ``embedding_guard`` elsewhere in this workspace:
7
+ the import-linter independence contract forbids atif-corpus from importing
8
+ atif-converter, and the agent identity is needed on both sides of that
9
+ boundary — the converter picks an adapter with it, the corpus picks a discovery
10
+ layout and a corpus slug with it.
11
+
12
+ ``packages/atif-corpus/tests/test_agents_twin_pin.py`` reads the atif-converter
13
+ copy as SOURCE TEXT (AST, never imported) and fails if the two disagree on a
14
+ member name or a value, so the twin cannot drift silently.
15
+
16
+ The string values are WIRE CONTRACT: the ``--agent`` flag's spellings, harbor's
17
+ ``Trajectory.agent.name``, and the ``agent`` key in ``meta.json``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from enum import StrEnum
23
+
24
+
25
+ class AgentSource(StrEnum):
26
+ """A coding agent whose transcripts this workspace can materialize."""
27
+
28
+ CLAUDE_CODE = "claude-code"
29
+ CODEX = "codex"
30
+
31
+
32
+ #: The agent assumed when a caller names none.
33
+ DEFAULT_AGENT: AgentSource = AgentSource.CLAUDE_CODE
@@ -0,0 +1,34 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Pure rule: does an artifact set's recorded provenance match what this pass writes.
4
+
5
+ The watermark answers "did the SOURCE move?". It cannot answer "would this
6
+ version of the code write the same artifacts?", so a converter upgrade or a
7
+ columnar schema bump used to leave every existing session on the old output
8
+ forever. The *generation* is the second question: a small mapping of
9
+ ``meta.json`` keys to the values this pass stamps (``converter_schema``, the
10
+ converter's own output version, and ``columnar_schema`` when the columnar
11
+ producer runs), and a session whose
12
+ recorded values differ is stale even when no source byte changed.
13
+
14
+ Only the EXPECTED keys are compared. A pass that does not write columnar
15
+ artifacts expects no ``columnar_schema``, so it neither re-converts a session
16
+ that has one nor one that lacks one.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping
25
+
26
+
27
+ def generation_matches(recorded: Mapping[str, object], expected: Mapping[str, object]) -> bool:
28
+ """True when every expected key is recorded with exactly the expected value.
29
+
30
+ A missing key does not match: a session materialized before a key existed
31
+ (``columnar_schema`` on a ``--no-columnar`` session) is exactly the one the
32
+ new generation has to reach.
33
+ """
34
+ return all(key in recorded and recorded[key] == value for key, value in expected.items())
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The contract's materialized-corpus directory shape, as one value object.
4
+
5
+ CONTRACT.md §Materialized corpus layout is the ONLY specification of where
6
+ artifacts live; atif-duck reads this same shape without importing us, so the
7
+ paths must be computed in exactly one place on the writer side. This module
8
+ is that place — every writer path in the application layer goes through
9
+ :class:`CorpusLayout`, and the layout tests pin the contract strings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ #: Per-session artifact filenames fixed by CONTRACT.md.
19
+ TRAJECTORY_FILENAME = "trajectory.json"
20
+ LOSS_REPORT_FILENAME = "loss_report.json"
21
+ EDGES_FILENAME = "edges.jsonl"
22
+ #: One line per kept non-message record (hooks, API errors, cost-state, ...).
23
+ SESSION_EVENTS_FILENAME = "session_events.jsonl"
24
+ META_FILENAME = "meta.json"
25
+ WATERMARK_FILENAME = "watermark.json"
26
+ #: Corpus-level record of sessions whose source converted to nothing.
27
+ EMPTY_SESSIONS_FILENAME = "empty_sessions.json"
28
+ #: Corpus-level record of published sessions a ``SessionSink`` has not yet
29
+ #: taken (a failed or interrupted sync); retried on the next pass.
30
+ SINK_PENDING_FILENAME = "sink_pending.json"
31
+ #: Per-session directory holding the zstd copy of the raw source files.
32
+ SOURCE_ARCHIVE_DIRNAME = "source"
33
+
34
+ #: The suffix a compressed artifact carries after its logical name.
35
+ COMPRESSED_SUFFIX = ".zst"
36
+ #: The artifacts materialize stores zstd-compressed, as ``<name>.zst``: the
37
+ #: bulk of a corpus's bytes. ``meta.json`` and ``loss_report.json`` stay plain
38
+ #: (small, and ``meta.json`` is what every reader and the writer check first).
39
+ #: A session written before this keeps its plain files until ``atif-sql corpus
40
+ #: slim`` compresses them; readers accept either spelling. atif-duck carries
41
+ #: the twin names (``atif_duck.domain.artifacts``), pinned by atif-cli's tests.
42
+ COMPRESSED_ARTIFACT_FILENAMES: tuple[str, ...] = (
43
+ TRAJECTORY_FILENAME,
44
+ EDGES_FILENAME,
45
+ SESSION_EVENTS_FILENAME,
46
+ )
47
+
48
+
49
+ def stored_filename(name: str) -> str:
50
+ """The name a NEW session stores the artifact ``name`` under."""
51
+ return f"{name}{COMPRESSED_SUFFIX}" if name in COMPRESSED_ARTIFACT_FILENAMES else name
52
+
53
+
54
+ #: A blob's name: the lowercase hex SHA-256 of its bytes.
55
+ _BLOB_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
56
+ #: A blob's extension: short, lowercase alphanumeric (``png``, ``jpg``, ``bin``).
57
+ _BLOB_EXTENSION_RE = re.compile(r"^[a-z0-9]{1,8}$")
58
+
59
+
60
+ class InvalidBlobNameError(ValueError):
61
+ """A blob hash or extension that must not become part of a corpus path."""
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class CorpusLayout:
66
+ """Computes every contract path under one ``corpus_root``.
67
+
68
+ Pure path arithmetic — nothing here touches the filesystem, so the
69
+ layout can be asserted against the contract without a tmpdir.
70
+ """
71
+
72
+ #: Root of the materialized corpus (default ``~/.atif-sql/corpus/<slug>``).
73
+ corpus_root: Path
74
+
75
+ @property
76
+ def sessions_dir(self) -> Path:
77
+ """Parent directory of every per-session artifact directory."""
78
+ return self.corpus_root / "sessions"
79
+
80
+ @property
81
+ def watermark_path(self) -> Path:
82
+ """``{path: mtime_ns}`` across the source corpus, updated per pass."""
83
+ return self.corpus_root / WATERMARK_FILENAME
84
+
85
+ @property
86
+ def staging_dir(self) -> Path:
87
+ """Scratch area for whole-session-dir atomic swaps.
88
+
89
+ Deliberately OUTSIDE ``sessions/`` so no reader glob (DuckDB's
90
+ ``read_json`` matches dot-dirs) and no source-removal walk can ever
91
+ observe a half-written session dir; same filesystem as
92
+ ``sessions/`` so ``os.replace`` of the staged dir stays atomic.
93
+ """
94
+ return self.corpus_root / ".staging"
95
+
96
+ @property
97
+ def empty_sessions_path(self) -> Path:
98
+ """``{session_id: generation}`` for sessions whose source held nothing to convert.
99
+
100
+ Kept apart from ``watermark.json`` on purpose: that file's shape is
101
+ ``{path: mtime_ns}`` and every reader coerces its values to ``int``, so
102
+ a structured entry there would read as corruption and cost a full
103
+ re-materialization on any version that predates this file.
104
+ """
105
+ return self.corpus_root / EMPTY_SESSIONS_FILENAME
106
+
107
+ @property
108
+ def sink_pending_path(self) -> Path:
109
+ """``{"session_ids": [...]}``: published sessions the session sink still owes.
110
+
111
+ Written BEFORE a pass publishes anything (every session it may
112
+ publish, plus what was already pending) and rewritten after the sink
113
+ ran, so a pass that dies between a session's swap and its sync still
114
+ leaves the session recorded.
115
+ """
116
+ return self.corpus_root / SINK_PENDING_FILENAME
117
+
118
+ @property
119
+ def blobs_dir(self) -> Path:
120
+ """The content-addressed attachment store shared by every session.
121
+
122
+ Outside ``sessions/`` on purpose: no reader glob and no per-session
123
+ swap touches it, retention never deletes from it, and a blob two
124
+ sessions share is stored once.
125
+ """
126
+ return self.corpus_root / "blobs"
127
+
128
+ def blob_path(self, sha256: str, extension: str) -> Path:
129
+ """``<corpus_root>/blobs/sha256/<first two hex>/<sha256>.<extension>``.
130
+
131
+ Raises
132
+ ------
133
+ InvalidBlobNameError
134
+ ``sha256`` is not 64 lowercase hex digits or ``extension`` is not
135
+ one to eight lowercase letters and digits.
136
+ """
137
+ if not _BLOB_HASH_RE.match(sha256) or not _BLOB_EXTENSION_RE.match(extension):
138
+ msg = f"invalid blob name {sha256!r}.{extension!r}"
139
+ raise InvalidBlobNameError(msg)
140
+ return self.blobs_dir / "sha256" / sha256[:2] / f"{sha256}.{extension}"
141
+
142
+ def session_dir(self, session_id: str) -> Path:
143
+ """``<corpus_root>/sessions/<session_id>/``."""
144
+ return self.sessions_dir / session_id
145
+
146
+ def trajectory_path(self, session_id: str) -> Path:
147
+ """Compact ATIF-v1.7 trajectory JSON for one session, as materialize stores it (zstd)."""
148
+ return self.session_dir(session_id) / stored_filename(TRAJECTORY_FILENAME)
149
+
150
+ def loss_report_path(self, session_id: str) -> Path:
151
+ """``atif_converter`` LossReport JSON for one session."""
152
+ return self.session_dir(session_id) / LOSS_REPORT_FILENAME
153
+
154
+ def edges_path(self, session_id: str) -> Path:
155
+ """One line per RAW record: uuid/parent_uuid edge list, as materialize stores it (zstd)."""
156
+ return self.session_dir(session_id) / stored_filename(EDGES_FILENAME)
157
+
158
+ def session_events_path(self, session_id: str) -> Path:
159
+ """One line per kept non-message record, as materialize stores it (zstd)."""
160
+ return self.session_dir(session_id) / stored_filename(SESSION_EVENTS_FILENAME)
161
+
162
+ def meta_path(self, session_id: str) -> Path:
163
+ """Provenance record: source files, mtimes, versions, materialized_at."""
164
+ return self.session_dir(session_id) / META_FILENAME
165
+
166
+ def source_archive_dir(self, session_id: str) -> Path:
167
+ """The raw source archive: one zstd ``<relative path>.zst`` per source file."""
168
+ return self.session_dir(session_id) / SOURCE_ARCHIVE_DIRNAME
@@ -0,0 +1,175 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The ports materialization needs filled: a converter and, optionally, an artifact producer and a session sink.
4
+
5
+ atif-corpus may never import atif-converter or atif-duck (import-linter
6
+ independence contract), so both Protocols are typed to CONTRACT.md's artifact
7
+ shapes rather than to either package's internals. atif-cli adapts the real
8
+ converter and the real columnar producer to these ports; tests use
9
+ :class:`atif_corpus.infrastructure.fake_converter.FakeConverter` and a fake
10
+ producer of their own.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING, Any, Protocol
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping, Sequence
20
+ from pathlib import Path
21
+
22
+
23
+ class EmptySourceError(Exception):
24
+ """The session holds nothing to convert (no user or assistant record).
25
+
26
+ Not a failure: the transcript is well-formed and simply empty, and it stays
27
+ empty until something writes to it. An adapter translates its converter's
28
+ own "empty" verdict into this so the use case can record the session as
29
+ empty, keyed on its source mtimes, instead of retrying it every pass. Any
30
+ OTHER exception is a real failure.
31
+ """
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ArchivedSource:
36
+ """One file of a session's raw source archive, as the converter wrote it.
37
+
38
+ The compressed copy lives at ``<archive_dir>/<relative_path>.zst``;
39
+ ``size`` and ``sha256`` describe the UNCOMPRESSED bytes, so a restore can
40
+ prove it got the original back.
41
+ """
42
+
43
+ #: POSIX path relative to the main transcript's parent directory.
44
+ relative_path: str
45
+ #: Uncompressed size in bytes.
46
+ size: int
47
+ #: Hex sha256 of the uncompressed bytes.
48
+ sha256: str
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class BlobOutput:
53
+ """One attachment's bytes, content-addressed, for the corpus blob store.
54
+
55
+ ``sha256`` is the lowercase hex digest of ``data`` and ``extension`` the
56
+ file extension its media type maps to (``png``, ``jpg``, ``pdf``, ``bin``);
57
+ the writer validates both before either becomes part of a path.
58
+ """
59
+
60
+ sha256: str
61
+ extension: str
62
+ data: bytes
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class ConversionOutput:
67
+ """Everything one conversion yields that the corpus writes to disk.
68
+
69
+ Shapes are the contract's, pre-serialized by the adapter:
70
+
71
+ * ``trajectory_dict`` — the ATIF-v1.7 trajectory, ready for compact
72
+ ``json.dumps(separators=(",", ":"))``.
73
+ * ``loss_report_dict`` — ``LossReport.to_json()``-shaped dict.
74
+ * ``edges_lines`` — one already-serialized JSON line per RAW record
75
+ (uuid, parent_uuid, message_id, type, ts, is_sidechain,
76
+ is_compact_summary, source_file, tool_use_ids), WITHOUT trailing
77
+ newlines; the writer owns line termination.
78
+ * ``events_lines`` — one already-serialized JSON line per kept
79
+ NON-MESSAGE record (``session_events.jsonl``: seq, ts, event_type,
80
+ subtype, uuid, parent_uuid, tool_use_id, is_sidechain, source_file,
81
+ payload, payload_bytes, payload_truncated), same termination rule.
82
+ Empty by default, so a converter that has none writes an empty file.
83
+ * ``source_archive`` — the files the converter archived into the
84
+ ``archive_dir`` it was handed (empty when it was handed none, or when
85
+ the adapter does not archive). Every entry must exist on disk as
86
+ ``<archive_dir>/<relative_path>.zst``; the use case checks that.
87
+ * ``blobs`` — the inline attachments the converter replaced with a
88
+ ``[image sha256:<hash> ...]`` placeholder, one per distinct hash. The
89
+ writer stores each under ``<corpus_root>/blobs/`` BEFORE it publishes
90
+ the session that references it.
91
+ """
92
+
93
+ trajectory_dict: dict[str, Any]
94
+ loss_report_dict: dict[str, Any]
95
+ edges_lines: list[str]
96
+ events_lines: list[str] = field(default_factory=list)
97
+ source_archive: tuple[ArchivedSource, ...] = ()
98
+ blobs: tuple[BlobOutput, ...] = ()
99
+
100
+
101
+ class ConverterPort(Protocol):
102
+ """Anything that can turn one session JSONL into corpus artifacts.
103
+
104
+ Implementations may raise any exception: the materialize use case
105
+ records the failure against the session and continues — one broken
106
+ transcript must never abort a corpus sync. :class:`EmptySourceError` is
107
+ the one exception with its own meaning: the session is empty, not broken.
108
+ """
109
+
110
+ def convert(self, session_jsonl: Path, *, archive_dir: Path | None = None) -> ConversionOutput:
111
+ """Convert one session (main JSONL + its side-files) to artifacts.
112
+
113
+ With ``archive_dir``, also write a zstd copy of every source file of the
114
+ session under it, named by its path relative to ``session_jsonl``'s
115
+ parent plus ``.zst``, and list them in
116
+ :attr:`ConversionOutput.source_archive`. The copy must be of the bytes
117
+ the artifacts were built from; an adapter that cannot promise that
118
+ should archive nothing rather than something else.
119
+ """
120
+ ...
121
+
122
+
123
+ class ArtifactProducer(Protocol):
124
+ """Anything that writes EXTRA per-session artifacts beside the contract ones.
125
+
126
+ The materialize use case calls :meth:`produce` once per session, inside
127
+ the staged session directory, after ``trajectory.json`` /
128
+ ``loss_report.json`` / ``edges.jsonl`` / ``session_events.jsonl`` are
129
+ written (so a producer may read those staged files) and before
130
+ ``meta.json`` is. Whatever the producer writes therefore publishes
131
+ atomically with the contract artifacts (the whole directory is
132
+ swapped into place) and is covered by the same completeness marker.
133
+
134
+ The producer returns extra keys for ``meta.json``. They must not collide
135
+ with the contract's own keys; the use case treats a collision as a
136
+ programming error and fails the session.
137
+
138
+ Implementations may raise any exception: the use case records the
139
+ failure against the session, never publishes the staged directory, and
140
+ retries the session next pass, exactly as it does for a converter
141
+ failure.
142
+ """
143
+
144
+ def produce(
145
+ self,
146
+ session_dir: Path,
147
+ *,
148
+ session_id: str,
149
+ trajectory: Mapping[str, Any],
150
+ ) -> Mapping[str, Any]:
151
+ """Write extra artifacts for ``session_id`` into ``session_dir``; return meta extras."""
152
+ ...
153
+
154
+
155
+ class SessionSink(Protocol):
156
+ """Anything that keeps a copy of published sessions in step with the corpus.
157
+
158
+ atif-cli plugs atif-duck's DuckLake writer in here. The materialize use
159
+ case calls :meth:`sync_sessions` in the PARENT process only (never in a
160
+ pool worker, so an implementation need not pickle), after every session
161
+ in ``session_ids`` has been swapped into ``sessions/<id>/`` (or had its
162
+ ``meta.json`` rewritten as source-removed), one batch at a time, in plan
163
+ order.
164
+
165
+ Returning normally means the copy now matches those sessions' published
166
+ artifacts (an implementation with no copy to keep, say because none has
167
+ been created yet, may simply return). Raising means it may not: the use
168
+ case records every session of the batch, and every later batch, in
169
+ ``<corpus_root>/sink_pending.json`` and hands them to the sink again next
170
+ pass, so the copy never silently diverges from the corpus.
171
+ """
172
+
173
+ def sync_sessions(self, *, corpus_root: Path, agent: str, session_ids: Sequence[str]) -> None:
174
+ """Make the copy's rows for ``session_ids`` equal their published artifacts."""
175
+ ...
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The session id boundary: which directory names may become a session.
4
+
5
+ A session id is the one piece of text from OUTSIDE this workspace that ends up
6
+ inside a corpus path: it is derived from a transcript filename under the
7
+ source root, becomes the ``sessions/<session_id>/`` directory name, and from
8
+ there names the files atif-duck opens. Everything else in a statement is a
9
+ constant. So the id is checked once, at the boundary, and a name that fails
10
+ the check is skipped and reported rather than carried any further.
11
+
12
+ The rule is deliberately narrow: an ASCII letter or digit first, then letters,
13
+ digits, ``.``, ``_`` and ``-``, at most 255 characters (the longest name most
14
+ filesystems accept). Every id both supported agents produce passes: Claude
15
+ Code names a session with a version 4 UUID, Codex with a version 7 UUID
16
+ (``019e9e38-39a6-7170-86fb-46e6f1cb1931``). Quotes, whitespace, path
17
+ separators, ``;``, ``$``, ``?`` and anything outside ASCII fail.
18
+
19
+ DELIBERATE TWIN of :mod:`atif_duck.domain.session_id`, the same way
20
+ :mod:`atif_corpus.domain.agents` twins the converter's enum: the independence
21
+ contract forbids the two packages from importing each other, and both sides
22
+ must apply the SAME rule (the corpus writer before it writes, the registry
23
+ before it reads a corpus an older version wrote). Each package's tests read
24
+ the other's copy as source text and fail if the pattern or the limit drifts.
25
+
26
+ Pure module: stdlib only.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+
33
+ #: The accepted shape, as a pattern string so the twin pin can compare it.
34
+ SESSION_ID_PATTERN: str = r"^[A-Za-z0-9][A-Za-z0-9._-]*$"
35
+
36
+ #: Longest accepted id, in characters.
37
+ SESSION_ID_MAX_CHARS: int = 255
38
+
39
+ _SESSION_ID_RE = re.compile(SESSION_ID_PATTERN)
40
+
41
+
42
+ def session_id_rejection(candidate: str) -> str | None:
43
+ """Why ``candidate`` cannot be a session id, or ``None`` when it can.
44
+
45
+ The reason is one short phrase meant for a log line; a caller that only
46
+ needs the verdict uses :func:`is_valid_session_id`.
47
+ """
48
+ if len(candidate) > SESSION_ID_MAX_CHARS:
49
+ return f"longer than {SESSION_ID_MAX_CHARS} characters"
50
+ # fullmatch, not match: with ``$`` alone a trailing newline would pass.
51
+ if _SESSION_ID_RE.fullmatch(candidate) is None:
52
+ return (
53
+ "contains a character outside [A-Za-z0-9._-] or does not start with a letter or digit"
54
+ )
55
+ return None
56
+
57
+
58
+ def is_valid_session_id(candidate: str) -> bool:
59
+ """Whether ``candidate`` passes the boundary."""
60
+ return session_id_rejection(candidate) is None
61
+
62
+
63
+ __all__ = [
64
+ "SESSION_ID_MAX_CHARS",
65
+ "SESSION_ID_PATTERN",
66
+ "is_valid_session_id",
67
+ "session_id_rejection",
68
+ ]