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,412 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Post-conversion enrichment: restore what harbor 0.22.0 drops.
4
+
5
+ Pure function over a trajectory dict + raw records — no harbor import, no
6
+ I/O. Repairs three fidelity gaps wrap-locally (fidelity.py names them):
7
+
8
+ - gap 7 (``UUID_NOT_PRESERVED``): ``step.extra["source_uuids"]`` lists the
9
+ raw record uuids that contributed to each step;
10
+ - gap 5 (``COMPACT_SUMMARY_UNHANDLED``): ``step.extra["is_compact_summary"]``
11
+ when any contributing record carried ``isCompactSummary``;
12
+ - gap 6 (``CACHE_SPLIT_PARTIAL``): ``trajectory.extra["cache_creation_total"]``
13
+ surfaced from ``final_metrics.extra``.
14
+
15
+ MATCHING STRATEGY: harbor 0.22.0 puts no record identity in ``step.extra`` —
16
+ it reads ``requestId`` off the MESSAGE dict and ``id`` off the EVENT dict,
17
+ whereas real transcripts carry ``requestId`` on the event and ``uuid`` on the
18
+ event, so neither lands in extra and there is nothing to join on. The pass
19
+ therefore re-runs harbor's deterministic normalization order:
20
+
21
+ - agent steps <- assistant records grouped by ``message.id``, groups in
22
+ first-appearance order over the uuid-deduped, timestamp-sorted record walk
23
+ (exactly harbor's ``turn_by_msgid`` bundling); cross-checked via
24
+ ``tool_calls[].call_id`` vs the group's tool_use block ids when present;
25
+ - tool_result user records <- joined into the agent step whose
26
+ ``tool_calls`` / ``observation.results[].source_call_id`` contain their
27
+ ``tool_use_id``;
28
+ - plain user text steps <- 1:1 walk of raw user records that produce a
29
+ visible text message (harbor's rules replicated), in timestamp order.
30
+
31
+ Known edge cases:
32
+ - a user record mixing text blocks AND tool_result blocks contributes to
33
+ BOTH a user step (text) and an agent step (result attachment) — its uuid
34
+ appears in both steps' ``source_uuids``;
35
+ - orphan tool_results (no pending call, e.g. replayed after compaction)
36
+ become standalone harbor AGENT steps interleaved in event order. They have
37
+ no assistant record, so they must NOT consume a ``message.id`` group in
38
+ the positional zip — the pass detects them (every one of the step's tool
39
+ call ids is absent from every assistant group's tool_use ids), keeps them
40
+ out of the zip, and attributes each one from the tool_result-carrying
41
+ user record via ``tool_use_id`` when possible (else it stays
42
+ unattributed);
43
+ - assistant records without ``message.id`` each form their own group,
44
+ mirroring harbor's one-turn-per-event fallback.
45
+
46
+ DESYNC ACCOUNTING (refuse-and-log, never guess): the agent walk advances a
47
+ (step, group) pair only when the tool-id cross-check passes — the ids
48
+ intersect, or BOTH sides are text-only (no tool ids on either side). The
49
+ first mismatching pair STOPS agent attribution outright: once positions
50
+ disagree, every later pairing is suspect, so continuing would misattribute.
51
+ The user walk likewise advances only while the record's harbor-derived text
52
+ equals the step's message. Either walk halting (mismatch or leftover steps)
53
+ records the first refused step in
54
+ ``trajectory.extra["enrichment_truncated_at_step"]`` and logs a warning.
55
+ After both walks, any step left with no ``source_uuids`` and any group /
56
+ user text record never attached to a step are counted and surfaced as
57
+ ``trajectory.extra["enrichment_unattributed_steps"]`` /
58
+ ``trajectory.extra["enrichment_leftover_groups"]`` (only present when
59
+ nonzero). ``extra`` is free-form per the contract, so this is
60
+ contract-compatible; a truncation marker or nonzero count means the
61
+ positional re-derivation disagreed with harbor and the affected
62
+ attributions were REFUSED, not guessed.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import copy
68
+ import json
69
+ from collections.abc import Sequence
70
+ from dataclasses import dataclass, field
71
+ from typing import Any
72
+
73
+ from loguru import logger
74
+
75
+ _SOURCE_UUIDS = "source_uuids"
76
+ _TRUNCATED_AT = "enrichment_truncated_at_step"
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class _AssistantGroup:
81
+ """One harbor turn: assistant records sharing a ``message.id``."""
82
+
83
+ uuids: list[str] = field(default_factory=list)
84
+ compact: bool = False
85
+ tool_use_ids: set[str] = field(default_factory=set)
86
+
87
+
88
+ def _dedup_and_sort(records: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
89
+ """Replicate harbor's pre-pass: dedup by uuid, then stable-sort by ts."""
90
+ seen: set[str] = set()
91
+ deduped: list[dict[str, Any]] = []
92
+ for record in records:
93
+ uuid = record.get("uuid")
94
+ if isinstance(uuid, str) and uuid:
95
+ if uuid in seen:
96
+ continue
97
+ seen.add(uuid)
98
+ deduped.append(record)
99
+ deduped.sort(key=lambda r: r.get("timestamp") or "")
100
+ return deduped
101
+
102
+
103
+ def _stringify(value: Any) -> str:
104
+ """Harbor's ``_stringify``, replicated: str verbatim, else JSON, else str."""
105
+ if isinstance(value, str):
106
+ return value
107
+ try:
108
+ return json.dumps(value, ensure_ascii=False)
109
+ except TypeError:
110
+ return str(value)
111
+
112
+
113
+ def _visible_user_text(message: dict[str, Any]) -> str | None:
114
+ """The user-step message text harbor would emit for this record, or None.
115
+
116
+ Replicates harbor 0.22.0's user-content rules exactly (including the
117
+ finding-5 divergence: a ``{"type": "text", "text": <non-str>}`` block is
118
+ NOT skipped — harbor json-encodes the whole block into a text part, so
119
+ the record still produces a visible step). Returning the text (not just
120
+ a bool) lets the user-step walk cross-check each pairing instead of
121
+ trusting position alone.
122
+ """
123
+ content = message.get("content")
124
+ if isinstance(content, str):
125
+ return content if content.strip() else None
126
+ if isinstance(content, list):
127
+ parts: list[str] = []
128
+ for block in content:
129
+ if isinstance(block, dict) and block.get("type") == "tool_result":
130
+ continue
131
+ if (
132
+ isinstance(block, dict)
133
+ and block.get("type") == "text"
134
+ and isinstance(block.get("text"), str)
135
+ ):
136
+ parts.append(block["text"])
137
+ continue
138
+ # any other block (incl. text blocks with non-str text) is
139
+ # stringified by harbor into a text part
140
+ parts.append(_stringify(block))
141
+ text = "\n\n".join(part for part in parts if part.strip())
142
+ return text or None
143
+ if content not in (None, ""):
144
+ text = _stringify(content)
145
+ return text if text.strip() else None
146
+ return None
147
+
148
+
149
+ def _tool_result_ids(message: dict[str, Any]) -> list[str]:
150
+ content = message.get("content")
151
+ if not isinstance(content, list):
152
+ return []
153
+ ids: list[str] = []
154
+ for block in content:
155
+ if isinstance(block, dict) and block.get("type") == "tool_result":
156
+ tool_use_id = block.get("tool_use_id")
157
+ if isinstance(tool_use_id, str) and tool_use_id:
158
+ ids.append(tool_use_id)
159
+ return ids
160
+
161
+
162
+ def _step_call_ids(step: dict[str, Any]) -> set[str]:
163
+ """All tool call ids a step owns: tool_calls plus observation results."""
164
+ ids: set[str] = set()
165
+ for call in step.get("tool_calls") or []:
166
+ call_id = call.get("tool_call_id") or call.get("call_id")
167
+ if isinstance(call_id, str) and call_id:
168
+ ids.add(call_id)
169
+ observation = step.get("observation") or {}
170
+ for result in observation.get("results") or []:
171
+ source_call_id = result.get("source_call_id")
172
+ if isinstance(source_call_id, str) and source_call_id:
173
+ ids.add(source_call_id)
174
+ return ids
175
+
176
+
177
+ def _step_id(step: dict[str, Any]) -> int | None:
178
+ """The step's ``step_id`` when it is an int (ATIF requires it; be safe)."""
179
+ value = step.get("step_id")
180
+ return value if isinstance(value, int) else None
181
+
182
+
183
+ def _attach(step: dict[str, Any], uuids: list[str], *, compact: bool) -> None:
184
+ extra = step.get("extra")
185
+ if not isinstance(extra, dict):
186
+ extra = {}
187
+ step["extra"] = extra
188
+ existing = extra.get(_SOURCE_UUIDS)
189
+ merged: list[str] = list(existing) if isinstance(existing, list) else []
190
+ for uuid in uuids:
191
+ if uuid not in merged:
192
+ merged.append(uuid)
193
+ extra[_SOURCE_UUIDS] = merged
194
+ if compact:
195
+ extra["is_compact_summary"] = True
196
+
197
+
198
+ def enrich_trajectory(
199
+ trajectory: dict[str, Any],
200
+ raw_records: Sequence[dict[str, Any]],
201
+ *,
202
+ copy_input: bool = True,
203
+ ) -> dict[str, Any]:
204
+ """Return the enriched trajectory.
205
+
206
+ ``raw_records`` are the parsed records of EVERY discovered session file
207
+ (main + all side-files); order does not matter — the pass re-applies
208
+ harbor's dedup + timestamp sort itself.
209
+
210
+ ``copy_input=True`` (default) leaves ``trajectory`` untouched. Pass
211
+ ``False`` when the caller owns the only reference and wants the mutation
212
+ in place: the deep copy duplicates the whole step list, which buys
213
+ nothing when the original is discarded straight afterwards.
214
+ """
215
+ enriched = copy.deepcopy(trajectory) if copy_input else trajectory
216
+ records = _dedup_and_sort(raw_records)
217
+
218
+ # -- assistant records: group by message.id in first-appearance order.
219
+ group_order: list[str] = []
220
+ groups: dict[str, _AssistantGroup] = {}
221
+ # tool_use_id -> contributing user (tool_result) records
222
+ results_by_call_id: dict[str, list[dict[str, Any]]] = {}
223
+ user_text_records: list[dict[str, Any]] = []
224
+
225
+ for record in records:
226
+ message = record.get("message")
227
+ if not isinstance(message, dict):
228
+ continue
229
+ record_type = record.get("type")
230
+ uuid = record.get("uuid")
231
+ uuid_str = uuid if isinstance(uuid, str) and uuid else None
232
+ compact = bool(record.get("isCompactSummary", False))
233
+
234
+ if record_type == "assistant":
235
+ msg_id = message.get("id")
236
+ key = msg_id if isinstance(msg_id, str) and msg_id else f"__event__{id(record)}"
237
+ group = groups.get(key)
238
+ if group is None:
239
+ group = _AssistantGroup()
240
+ groups[key] = group
241
+ group_order.append(key)
242
+ if uuid_str:
243
+ group.uuids.append(uuid_str)
244
+ group.compact = group.compact or compact
245
+ content = message.get("content")
246
+ if isinstance(content, list):
247
+ for block in content:
248
+ if isinstance(block, dict) and block.get("type") == "tool_use":
249
+ block_id = block.get("id")
250
+ if isinstance(block_id, str) and block_id:
251
+ group.tool_use_ids.add(block_id)
252
+ elif record_type == "user":
253
+ for call_id in _tool_result_ids(message):
254
+ results_by_call_id.setdefault(call_id, []).append(record)
255
+ if _visible_user_text(message) is not None:
256
+ user_text_records.append(record)
257
+
258
+ steps = enriched.get("steps") or []
259
+ agent_steps = [s for s in steps if s.get("source") == "agent"]
260
+ user_steps = [s for s in steps if s.get("source") == "user"]
261
+
262
+ # -- partition agent steps: ORPHAN tool_result steps (harbor emits a
263
+ # standalone agent step for a tool_result with no pending call, e.g.
264
+ # replayed after compaction) have NO assistant record behind them, so
265
+ # they must not consume a message.id group in the positional zip below.
266
+ # Detection: the step owns tool call ids, and every one of them is
267
+ # absent from every assistant group's tool_use ids.
268
+ all_group_tool_use_ids: set[str] = set()
269
+ for group in groups.values():
270
+ all_group_tool_use_ids |= group.tool_use_ids
271
+ orphan_steps: list[dict[str, Any]] = []
272
+ genuine_agent_steps: list[dict[str, Any]] = []
273
+ for step in agent_steps:
274
+ step_ids = _step_call_ids(step)
275
+ if step_ids and step_ids.isdisjoint(all_group_tool_use_ids):
276
+ orphan_steps.append(step)
277
+ else:
278
+ genuine_agent_steps.append(step)
279
+
280
+ # -- orphan steps <- the tool_result-carrying user record, joined on
281
+ # tool_use_id when possible; otherwise the step stays unattributed
282
+ # (and is counted below).
283
+ for step in orphan_steps:
284
+ for call_id in sorted(_step_call_ids(step)):
285
+ for record in results_by_call_id.get(call_id, ()):
286
+ uuid = record.get("uuid")
287
+ if isinstance(uuid, str) and uuid:
288
+ _attach(
289
+ step,
290
+ [uuid],
291
+ compact=bool(record.get("isCompactSummary", False)),
292
+ )
293
+
294
+ # -- genuine agent steps <- assistant groups, walked in lockstep
295
+ # (harbor's turn order). The cross-check is REQUIRED to advance: a pair
296
+ # attributes only when the two sides' tool ids intersect, or when BOTH
297
+ # sides are text-only. The FIRST mismatching pair stops the walk — once
298
+ # positions disagree, every later pairing is suspect, so refusing from
299
+ # that point beats silently misattributing (module docstring). A side
300
+ # running out while the other has leftovers is the same desync; the
301
+ # accounting below surfaces it.
302
+ truncated_at: int | None = None
303
+ consumed_groups = 0
304
+ for step, key in zip(genuine_agent_steps, group_order, strict=False):
305
+ group = groups[key]
306
+ step_ids = _step_call_ids(step)
307
+ aligned = bool(step_ids & group.tool_use_ids) if (step_ids or group.tool_use_ids) else True
308
+ if not aligned:
309
+ truncated_at = _step_id(step)
310
+ logger.warning(
311
+ "enrichment: agent-step alignment mismatch at step {} "
312
+ "(step tool ids {} vs group tool_use ids {}); "
313
+ "attribution stopped from this step onward",
314
+ truncated_at,
315
+ sorted(step_ids),
316
+ sorted(group.tool_use_ids),
317
+ )
318
+ break
319
+ consumed_groups += 1
320
+ _attach(step, group.uuids, compact=group.compact)
321
+ # tool_result user records join the agent step that owns their call
322
+ for call_id in sorted(step_ids):
323
+ for record in results_by_call_id.get(call_id, ()):
324
+ uuid = record.get("uuid")
325
+ if isinstance(uuid, str) and uuid:
326
+ _attach(
327
+ step,
328
+ [uuid],
329
+ compact=bool(record.get("isCompactSummary", False)),
330
+ )
331
+ if truncated_at is None and consumed_groups < len(genuine_agent_steps):
332
+ # length mismatch: agent steps left over with no group to claim them.
333
+ truncated_at = _step_id(genuine_agent_steps[consumed_groups])
334
+ logger.warning(
335
+ "enrichment: {} agent step(s) beyond the {} assistant group(s); "
336
+ "attribution stopped at step {}",
337
+ len(genuine_agent_steps) - consumed_groups,
338
+ len(group_order),
339
+ truncated_at,
340
+ )
341
+
342
+ # -- plain user text steps <- ts-order walk of user text records, with
343
+ # the SAME refuse-and-log protection: a pair advances only while the
344
+ # record's harbor-derived text equals the step's message, and leftover
345
+ # steps stop the walk instead of being silently dropped.
346
+ consumed_user_records = 0
347
+ for step, record in zip(user_steps, user_text_records, strict=False):
348
+ message = record.get("message")
349
+ expected = _visible_user_text(message) if isinstance(message, dict) else None
350
+ if step.get("message") != expected:
351
+ step_id = _step_id(step)
352
+ logger.warning(
353
+ "enrichment: user-step alignment mismatch at step {} "
354
+ "(step message differs from the walked record's text); "
355
+ "user attribution stopped from this step onward",
356
+ step_id,
357
+ )
358
+ if truncated_at is None or (step_id is not None and step_id < truncated_at):
359
+ truncated_at = step_id
360
+ break
361
+ consumed_user_records += 1
362
+ uuid = record.get("uuid")
363
+ uuids = [uuid] if isinstance(uuid, str) and uuid else []
364
+ _attach(step, uuids, compact=bool(record.get("isCompactSummary", False)))
365
+ else:
366
+ if consumed_user_records < len(user_steps):
367
+ # length mismatch: user steps left over with no record.
368
+ step_id = _step_id(user_steps[consumed_user_records])
369
+ logger.warning(
370
+ "enrichment: {} user step(s) beyond the {} user text record(s); "
371
+ "attribution stopped at step {}",
372
+ len(user_steps) - consumed_user_records,
373
+ len(user_text_records),
374
+ step_id,
375
+ )
376
+ if truncated_at is None or (step_id is not None and step_id < truncated_at):
377
+ truncated_at = step_id
378
+
379
+ # -- desync accounting (see module docstring): count anything the walks
380
+ # left behind on EITHER side and surface it on trajectory.extra. A
381
+ # truncation marker / nonzero count means attribution was refused
382
+ # somewhere, never guessed.
383
+ unattributed_steps = sum(
384
+ 1 for step in steps if not (step.get("extra") or {}).get(_SOURCE_UUIDS)
385
+ )
386
+ leftover_groups = (len(group_order) - consumed_groups) + (
387
+ len(user_text_records) - consumed_user_records
388
+ )
389
+ if unattributed_steps or leftover_groups or truncated_at is not None:
390
+ trajectory_extra = enriched.get("extra")
391
+ if not isinstance(trajectory_extra, dict):
392
+ trajectory_extra = {}
393
+ enriched["extra"] = trajectory_extra
394
+ if unattributed_steps:
395
+ trajectory_extra["enrichment_unattributed_steps"] = unattributed_steps
396
+ if leftover_groups:
397
+ trajectory_extra["enrichment_leftover_groups"] = leftover_groups
398
+ if truncated_at is not None:
399
+ trajectory_extra[_TRUNCATED_AT] = truncated_at
400
+
401
+ # -- gap 6: surface cache_creation_total on trajectory.extra.
402
+ final_metrics = enriched.get("final_metrics") or {}
403
+ metrics_extra = final_metrics.get("extra") or {}
404
+ cache_creation = metrics_extra.get("total_cache_creation_input_tokens")
405
+ if isinstance(cache_creation, int):
406
+ trajectory_extra = enriched.get("extra")
407
+ if not isinstance(trajectory_extra, dict):
408
+ trajectory_extra = {}
409
+ enriched["extra"] = trajectory_extra
410
+ trajectory_extra["cache_creation_total"] = cache_creation
411
+
412
+ return enriched
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The domain error hierarchy for atif-converter.
4
+
5
+ Pure, dependency-free exception types. Callers map these to exit codes
6
+ themselves (atif-cli owns that mapping in ``atif_cli.errors``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+
14
+ class DomainError(Exception):
15
+ """Base for every atif-converter domain error.
16
+
17
+ Anything that crosses a layer boundary as an error value is a subclass of
18
+ this. Adapters may raise transport-specific exceptions internally, but the
19
+ application layer surfaces only ``DomainError`` subtypes.
20
+ """
21
+
22
+
23
+ class InvalidSessionInput(DomainError): # noqa: N818 — named as a terminal input verdict, not "*Error"
24
+ """The supplied path is not a Claude Code session JSONL file."""
25
+
26
+
27
+ class EmptySessionError(DomainError):
28
+ """Harbor produced no trajectory (no convertible events in the session)."""
29
+
30
+
31
+ class TrajectoryValidationError(DomainError):
32
+ """The converted trajectory failed harbor's ``TrajectoryValidator``.
33
+
34
+ Carries the validator's error list so callers can report the exact
35
+ schema violations.
36
+ """
37
+
38
+ def __init__(self, errors: list[str]) -> None:
39
+ super().__init__(f"trajectory failed validation with {len(errors)} error(s)")
40
+ self.errors = errors
41
+
42
+
43
+ class ConversionError(DomainError):
44
+ """Unexpected failure inside the harbor adapter during conversion."""
45
+
46
+
47
+ class SourceMutatedDuringConversion(DomainError): # noqa: N818 — names a race verdict, not a runtime fault
48
+ """A source file changed while the session was being converted.
49
+
50
+ The artifacts derived from the pre-change snapshot would disagree with
51
+ each other, so conversion refuses instead of publishing an inconsistent
52
+ generation. Retrying once the session goes quiet succeeds.
53
+ """
54
+
55
+ def __init__(self, session_jsonl: Path, mutated: tuple[Path, ...]) -> None:
56
+ super().__init__(
57
+ f"{len(mutated)} source file(s) changed while converting {session_jsonl}: "
58
+ f"{', '.join(str(path) for path in mutated)}"
59
+ )
60
+ self.mutated = mutated
@@ -0,0 +1,171 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The fidelity policy: what harbor's Claude Code -> ATIF conversion loses.
4
+
5
+ Pure value objects — no harbor import, no I/O. This module NAMES the known
6
+ upstream gaps (verified empirically against harbor==0.22.0 on 2026-08-22)
7
+ rather than fixing them; the converter's job today is honest loss accounting,
8
+ not repair. The unit tests pin each gap so a harbor version bump that changes
9
+ behavior trips the suite.
10
+
11
+ Codex CLI has its own taxonomy and its own gaps, in
12
+ :mod:`atif_converter.domain.codex_fidelity`. :class:`LossReport` is shared by
13
+ both: it holds whichever agent's record types and gaps it was built with, and
14
+ ``to_json()`` flattens either family to the same ``loss_report.json`` shape,
15
+ so atif-duck reads one projection for every agent.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from enum import Enum
22
+
23
+ from atif_converter.domain.codex_fidelity import CodexFidelityGap, CodexRecordType
24
+
25
+
26
+ class RecordType(Enum):
27
+ """Taxonomy of raw Claude Code session JSONL record types.
28
+
29
+ ``type`` field values seen in the wild, plus ``OTHER`` for anything new.
30
+ Only USER and ASSISTANT records are converted by harbor 0.22.0; everything
31
+ else is silently dropped (:attr:`FidelityGap.NON_MESSAGE_RECORDS_DROPPED`).
32
+ """
33
+
34
+ USER = "user"
35
+ ASSISTANT = "assistant"
36
+ SYSTEM = "system"
37
+ ATTACHMENT = "attachment"
38
+ QUEUE_OPERATION = "queue-operation"
39
+ MODE = "mode"
40
+ LAST_PROMPT = "last-prompt"
41
+ SUMMARY = "summary"
42
+ PR_LINK = "pr-link"
43
+ STARTED = "started"
44
+ RESULT = "result"
45
+ #: Claude Code's running session cost (``totalCostUSD`` + per-model usage).
46
+ COST_STATE = "cost-state"
47
+ PERMISSION_MODE = "permission-mode"
48
+ OTHER = "other"
49
+
50
+
51
+ #: Record types harbor 0.22.0 actually converts into ATIF steps.
52
+ CONVERTIBLE_RECORD_TYPES: frozenset[RecordType] = frozenset({RecordType.USER, RecordType.ASSISTANT})
53
+
54
+
55
+ class FidelityGap(Enum):
56
+ """The known conversion gaps, inherited from harbor 0.22.0's converter.
57
+
58
+ Each member documents one loss verified against harbor's Claude Code
59
+ conversion. Our converter (:mod:`atif_converter.domain.claude_code_conversion`)
60
+ is a parity port of it, so these losses are OURS now, by choice: the
61
+ parity oracle holds the port to harbor's output, and fixing a gap means
62
+ deciding to diverge from that oracle on purpose.
63
+ """
64
+
65
+ #: (1) RETIRED. ``workflow_subagents_missed`` named harbor's discovery,
66
+ #: ``rglob("subagents/*.jsonl")``, which cannot see workflow-nested side
67
+ #: files. Our converter discovers every ``*.jsonl`` under the session's side
68
+ #: directory itself, so the gap has no mechanism left to describe. The value
69
+ #: is not reused; a corpus materialized before the port still carries it.
70
+
71
+ #: (2) Only user/assistant events are converted; system / attachment /
72
+ #: queue-operation / mode / last-prompt / summary records are silently dropped.
73
+ #: The ones :mod:`atif_converter.domain.session_events` keeps (hooks, injected
74
+ #: context, API errors, compaction boundaries, cost-state, mode changes) land
75
+ #: in ``session_events.jsonl`` and count as ``records_captured``; the gap is
76
+ #: observed only when something is left over after that.
77
+ NON_MESSAGE_RECORDS_DROPPED = "non_message_records_dropped"
78
+
79
+ #: (3) The parentUuid tree is flattened by timestamp sort, not chain-walked,
80
+ #: so branch/rewind structure is lost.
81
+ PARENT_CHAIN_FLATTENED = "parent_chain_flattened"
82
+
83
+ #: (4) Subagent events are INLINED into the flat step list (marked only via
84
+ #: ``extra.is_sidechain``) instead of ``subagent_trajectories`` embedding.
85
+ SUBAGENTS_INLINED = "subagents_inlined"
86
+
87
+ #: (5) No ``isCompactSummary`` handling — compaction summaries are not
88
+ #: distinguished from ordinary user messages.
89
+ COMPACT_SUMMARY_UNHANDLED = "compact_summary_unhandled"
90
+
91
+ #: (6) Cache split partially preserved: ``cached_tokens`` = cache_read only;
92
+ #: cache_creation survives only inside ``metrics.extra``.
93
+ CACHE_SPLIT_PARTIAL = "cache_split_partial"
94
+
95
+ #: (7) The event-level ``uuid`` is read only for dedup and never lands in
96
+ #: ``Step.extra`` — step-to-raw-record identity is unrecoverable.
97
+ #: (Verified 2026-08-22: step.extra carries requestId / message id /
98
+ #: is_sidechain, never the event uuid.)
99
+ UUID_NOT_PRESERVED = "uuid_not_preserved"
100
+
101
+
102
+ #: Either agent's raw-record taxonomy. ``LossReport.record_counts`` is keyed
103
+ #: by one of these, and ``to_json()`` reads only ``.value``, so a report built
104
+ #: from Codex records serializes to the same shape as a Claude Code one.
105
+ type AnyRecordType = RecordType | CodexRecordType
106
+
107
+ #: Either agent's gap taxonomy. The two enums' values are disjoint (the Codex
108
+ #: members are namespaced ``codex_*``), so a mixed ``gaps_observed`` array
109
+ #: stays unambiguous about which policy each entry came from.
110
+ type AnyFidelityGap = FidelityGap | CodexFidelityGap
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class LossReport:
115
+ """Per-session loss accounting: raw-side census vs converted output.
116
+
117
+ ``record_counts`` is the raw census by :class:`RecordType`;
118
+ ``records_converted`` (became steps), ``records_captured`` (became
119
+ ``session_events`` rows) and ``records_dropped`` partition the total;
120
+ ``gaps_observed`` names which :class:`FidelityGap` members this session
121
+ actually exhibits (a session with no subagents cannot observe gap 1 or 4).
122
+
123
+ CENSUS SCOPE: ``record_counts`` covers every record of every ``*.jsonl``
124
+ discovered under the session's side directory, at ANY depth and whether
125
+ or not a ``subagents/`` part appears in its path. That is the same file
126
+ set the adapter stages into harbor and the same set edges.jsonl is built
127
+ from, so ``records_total`` equals the edges line count for any session.
128
+ A side-file outside ``subagents/`` therefore raises ``records_total``
129
+ (and, for a non-message type, ``records_dropped``) relative to a census
130
+ scoped to ``subagents/`` alone.
131
+ """
132
+
133
+ record_counts: dict[AnyRecordType, int] = field(default_factory=dict)
134
+ records_converted: int = 0
135
+ records_dropped: int = 0
136
+ records_captured: int = 0
137
+ gaps_observed: frozenset[AnyFidelityGap] = frozenset()
138
+ subagent_files_found: int = 0
139
+ subagent_files_convertible: int = 0
140
+ workflow_subagent_files_found: int = 0
141
+
142
+ @property
143
+ def records_total(self) -> int:
144
+ """Total raw records counted in the census."""
145
+ return sum(self.record_counts.values())
146
+
147
+ def to_json(self) -> dict[str, int | dict[str, int] | list[str]]:
148
+ """The CONTRACT ``loss_report.json`` document, JSON-serializable.
149
+
150
+ Named by docs/CONTRACT.md ("loss_report.json — atif_converter
151
+ LossReport.to_json()") and read back by atif-duck's
152
+ ``_LOSS_REPORT_COLUMNS`` projection, so the keys here are wire
153
+ contract. Enum members flatten to their string values;
154
+ ``gaps_observed`` is sorted for deterministic output.
155
+ """
156
+ return {
157
+ "record_counts": {
158
+ record_type.value: count
159
+ for record_type, count in sorted(
160
+ self.record_counts.items(), key=lambda kv: kv[0].value
161
+ )
162
+ },
163
+ "records_total": self.records_total,
164
+ "records_converted": self.records_converted,
165
+ "records_captured": self.records_captured,
166
+ "records_dropped": self.records_dropped,
167
+ "gaps_observed": sorted(gap.value for gap in self.gaps_observed),
168
+ "subagent_files_found": self.subagent_files_found,
169
+ "subagent_files_convertible": self.subagent_files_convertible,
170
+ "workflow_subagent_files_found": self.workflow_subagent_files_found,
171
+ }