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,124 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The fidelity policy for Codex CLI: what harbor's Codex converter loses.
4
+
5
+ Pure value objects — no harbor import, no I/O. The Claude Code half of this
6
+ policy lives in :mod:`atif_converter.domain.fidelity`; this module is its
7
+ Codex counterpart and follows the same discipline: NAME each upstream gap
8
+ rather than fix it, and let the unit tests pin it so a harbor bump that
9
+ changes behavior trips the suite.
10
+
11
+ Every member below was verified empirically on 2026-09-11 against
12
+ harbor==0.22.0's Codex conversion over real rollouts from ``~/.codex/sessions``
13
+ written by codex-cli 0.153.4 and 0.154.0. Our converter
14
+ (:mod:`atif_converter.domain.codex_conversion`) is a parity port of that
15
+ conversion, so these losses are ours now, by choice.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from enum import Enum
21
+
22
+
23
+ class CodexRecordType(Enum):
24
+ """Taxonomy of raw Codex rollout JSONL record types.
25
+
26
+ The top-level ``type`` field values seen in the wild, plus ``OTHER`` for
27
+ anything new. Only ``RESPONSE_ITEM`` records become ATIF steps;
28
+ ``EVENT_MSG`` and ``TURN_CONTEXT`` are read for turn ids and token
29
+ counts but produce no step of their own, and the rest are dropped
30
+ (:attr:`CodexFidelityGap.NON_ITEM_RECORDS_DROPPED`).
31
+ """
32
+
33
+ SESSION_META = "session_meta"
34
+ RESPONSE_ITEM = "response_item"
35
+ EVENT_MSG = "event_msg"
36
+ TURN_CONTEXT = "turn_context"
37
+ TOKEN_USAGE_RECORD = "token_usage_record" # noqa: S105 — a record type, not a secret
38
+ WORLD_STATE = "world_state"
39
+ COMPACTED = "compacted"
40
+ OTHER = "other"
41
+
42
+
43
+ #: Record types harbor 0.22.0 turns into ATIF steps. ``EVENT_MSG`` and
44
+ #: ``TURN_CONTEXT`` are deliberately absent: the converter reads them for
45
+ #: ``token_count`` metrics and turn ids, and emits no step for either, so
46
+ #: counting them as converted would overstate what the trajectory carries.
47
+ CODEX_CONVERTIBLE_RECORD_TYPES: frozenset[CodexRecordType] = frozenset(
48
+ {CodexRecordType.RESPONSE_ITEM}
49
+ )
50
+
51
+ #: ``response_item`` payload types that DO reach a step, so a rollout whose
52
+ #: response items are all of some other type is honestly reported as
53
+ #: converting none of them.
54
+ CODEX_CONVERTIBLE_ITEM_TYPES: frozenset[str] = frozenset(
55
+ {
56
+ "message",
57
+ "function_call",
58
+ "function_call_output",
59
+ "custom_tool_call",
60
+ "custom_tool_call_output",
61
+ "web_search_call",
62
+ }
63
+ )
64
+
65
+
66
+ class CodexFidelityGap(Enum):
67
+ """The known Codex conversion gaps, inherited from harbor 0.22.0.
68
+
69
+ Values are namespaced ``codex_*`` because they share the
70
+ ``gaps_observed`` array in ``loss_report.json`` with the Claude Code
71
+ gaps: a reader of one corpus must be able to tell which policy a gap
72
+ came from without consulting a second column.
73
+ """
74
+
75
+ #: (1) RETIRED. ``codex_single_rollout_per_directory`` named harbor's
76
+ #: directory globbing, which converted only ``max(*.jsonl)`` of a session
77
+ #: dir. Our converter reads the one rollout it is given, so there is no
78
+ #: directory and no gap. The value is not reused; a corpus materialized
79
+ #: before the port still carries it.
80
+
81
+ #: (2) Only ``response_item`` records become steps. ``world_state``,
82
+ #: ``token_usage_record`` and ``compacted`` records are dropped outright,
83
+ #: and ``event_msg`` / ``turn_context`` contribute only turn ids and token
84
+ #: counts. Verified: a 17-record rollout yields 7 steps.
85
+ NON_ITEM_RECORDS_DROPPED = "codex_non_item_records_dropped"
86
+
87
+ #: (3) ``compacted`` records carry the compaction summary plus the
88
+ #: ``replacement_history`` the model actually saw afterwards. Neither
89
+ #: reaches the trajectory, so a compaction boundary is invisible in the
90
+ #: step list and the pre-compaction turns simply stop.
91
+ COMPACTION_UNHANDLED = "codex_compaction_unhandled"
92
+
93
+ #: (4) Reasoning is read from ``reasoning.summary`` only. Recent Codex
94
+ #: builds ship an EMPTY summary and put the content in
95
+ #: ``encrypted_content``, so reasoning is lost entirely: verified on a
96
+ #: rollout with 4 reasoning records and 0 steps carrying
97
+ #: ``reasoning_content``.
98
+ REASONING_ENCRYPTED_DROPPED = "codex_reasoning_encrypted_dropped"
99
+
100
+ #: (5) ``tool_search_call`` / ``tool_search_output`` items (Codex's
101
+ #: deferred-tool-loading search) match no branch of the payload-type
102
+ #: dispatch, so a tool search and its result vanish without a warning.
103
+ TOOL_SEARCH_CALLS_DROPPED = "codex_tool_search_calls_dropped"
104
+
105
+ #: (6) ``response_item.payload.id`` (``msg_*`` / ``call_*`` / ``rs_*``) is
106
+ #: never carried into ``Step.extra``, so step-to-raw-record identity is
107
+ #: unrecoverable from the trajectory alone — the same shape of loss as
108
+ #: Claude Code's gap 7, and repaired the same way by the enrichment pass.
109
+ ITEM_IDS_NOT_PRESERVED = "codex_item_ids_not_preserved"
110
+
111
+ #: (7) A ``developer`` role message becomes ATIF ``source="system"``,
112
+ #: which is also where a genuine system message lands. The two are
113
+ #: indistinguishable in the trajectory; ``edges.jsonl`` keeps the raw
114
+ #: role, so the distinction survives in the corpus but not in the steps.
115
+ DEVELOPER_ROLE_FLATTENED_TO_SYSTEM = "codex_developer_role_flattened_to_system"
116
+
117
+
118
+ #: Gaps every harbor 0.22.0 Codex conversion exhibits regardless of rollout
119
+ #: content — the structural set, mirroring the Claude Code policy's.
120
+ CODEX_STRUCTURAL_GAPS: frozenset[CodexFidelityGap] = frozenset(
121
+ {
122
+ CodexFidelityGap.ITEM_IDS_NOT_PRESERVED,
123
+ }
124
+ )
@@ -0,0 +1,165 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """A Codex code-mode script's nested MCP calls and commands, as ATIF tool calls.
4
+
5
+ In code mode Codex calls one ``exec`` tool whose JavaScript calls
6
+ ``tools.mcp__<server>__<tool>(...)`` and ``tools.exec_command(...)``. The
7
+ rollout records each nested call as an ``event_msg`` ``item_completed`` item
8
+ (``McpToolCall`` or ``CommandExecution``, id ``exec-<uuid>``) between the
9
+ script's ``custom_tool_call`` and its ``custom_tool_call_output``, and harbor's
10
+ converter turns none of them into a tool call. Pure functions over one such
11
+ item: the :class:`~atif_converter.domain.atif.ToolCall` and
12
+ :class:`~atif_converter.domain.atif.ObservationResult` JSON it becomes. Which
13
+ script an item belongs to is :mod:`atif_converter.domain.result_signals`'s
14
+ attribution; that pass also writes the result's ``is_error`` / ``exit_code``.
15
+
16
+ The tool call
17
+ -------------
18
+ ``tool_call_id``
19
+ The item's own ``id``.
20
+ ``function_name``
21
+ ``mcp__<server>__<tool>`` for an MCP call, the name the script called;
22
+ ``exec_command`` for a command.
23
+ ``arguments``
24
+ The MCP call's ``arguments`` object; ``{"cmd": <script>}`` for a command,
25
+ unwrapped from Codex's ``[<shell>, "-c" | "-lc", <script>]`` argv.
26
+ ``extra``
27
+ ``nested_in`` (the ``exec`` call's id), ``via`` (``"exec"``) and, when the
28
+ item states it, ``duration_ms``.
29
+
30
+ The result's content is the MCP result's content blocks rendered the way the
31
+ port renders a top-level tool output's blocks
32
+ (:func:`~atif_converter.domain.codex_conversion.structured_output_text`), or a
33
+ command's ``aggregated_output``.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import shlex
40
+ from typing import Any, Final
41
+
42
+ from atif_converter.domain.codex_conversion import structured_output_text
43
+
44
+ #: Completed item types that become a nested tool call.
45
+ NESTED_CALL_ITEM_TYPES: Final[frozenset[str]] = frozenset({"McpToolCall", "CommandExecution"})
46
+
47
+ #: The ``function_name`` a nested command gets: the tool the script called.
48
+ COMMAND_TOOL_NAME: Final = "exec_command"
49
+
50
+ #: ``extra.via`` on every nested call: the tool that ran it.
51
+ VIA_EXEC: Final = "exec"
52
+
53
+ #: A shell's flags before the script in Codex's command argv.
54
+ _SHELL_SCRIPT_FLAGS: Final[frozenset[str]] = frozenset({"-c", "-lc"})
55
+
56
+ #: The length of a ``[<shell>, <flag>, <script>]`` argv.
57
+ _SHELL_ARGV_LENGTH: Final = 3
58
+
59
+ _NANOS_PER_MS: Final = 1_000_000
60
+ _MS_PER_SEC: Final = 1000
61
+
62
+
63
+ def _json_text(value: Any) -> str:
64
+ return json.dumps(value, ensure_ascii=False)
65
+
66
+
67
+ def _arguments(value: Any) -> dict[str, Any]:
68
+ """An MCP item's ``arguments`` as an object, by the port's rule for a call's arguments."""
69
+ if isinstance(value, str):
70
+ try:
71
+ value = json.loads(value)
72
+ except json.JSONDecodeError:
73
+ return {"input": value}
74
+ if value is None:
75
+ return {}
76
+ return value if isinstance(value, dict) else {"value": value}
77
+
78
+
79
+ def _command_text(command: Any) -> Any:
80
+ """The script a command ran: unwrapped from its shell argv, else the argv as one line."""
81
+ if isinstance(command, list) and all(isinstance(part, str) for part in command):
82
+ if len(command) == _SHELL_ARGV_LENGTH and command[1] in _SHELL_SCRIPT_FLAGS:
83
+ return command[2]
84
+ return shlex.join(command)
85
+ return command
86
+
87
+
88
+ def _duration_ms(item: dict[str, Any], payload: dict[str, Any]) -> float | None:
89
+ """The call's duration: the item's ``{secs, nanos}``, else the payload's two timestamps."""
90
+ duration = item.get("duration")
91
+ if isinstance(duration, dict):
92
+ secs, nanos = duration.get("secs"), duration.get("nanos")
93
+ if isinstance(secs, int) and isinstance(nanos, int):
94
+ return round(secs * _MS_PER_SEC + nanos / _NANOS_PER_MS, 3)
95
+ started, completed = payload.get("started_at_ms"), payload.get("completed_at_ms")
96
+ if isinstance(started, int) and isinstance(completed, int) and completed >= started:
97
+ return float(completed - started)
98
+ return None
99
+
100
+
101
+ def nested_tool_call(payload: dict[str, Any], exec_call_id: str) -> dict[str, Any]:
102
+ """The ``ToolCall`` JSON one ``item_completed`` payload's item becomes, nested in ``exec_call_id``."""
103
+ item: dict[str, Any] = payload["item"]
104
+ if item.get("type") == "CommandExecution":
105
+ function_name = COMMAND_TOOL_NAME
106
+ arguments: dict[str, Any] = {"cmd": _command_text(item.get("command"))}
107
+ else:
108
+ function_name = f"mcp__{item.get('server')}__{item.get('tool')}"
109
+ arguments = _arguments(item.get("arguments"))
110
+ extra: dict[str, Any] = {"nested_in": exec_call_id, "via": VIA_EXEC}
111
+ duration = _duration_ms(item, payload)
112
+ if duration is not None:
113
+ extra["duration_ms"] = duration
114
+ return {
115
+ "tool_call_id": item["id"],
116
+ "function_name": function_name,
117
+ "arguments": arguments,
118
+ "extra": extra,
119
+ }
120
+
121
+
122
+ def _text(value: Any) -> str:
123
+ return value if isinstance(value, str) else _json_text(value)
124
+
125
+
126
+ def _result_text(result: dict[str, Any]) -> str:
127
+ """An MCP result's text: its ``Err``, else its content blocks, else its JSON text."""
128
+ if "Err" in result:
129
+ return _text(result["Err"])
130
+ content = result.get("content")
131
+ if not isinstance(content, list):
132
+ return _json_text(result)
133
+ text = structured_output_text(content)
134
+ if text is not None:
135
+ return text
136
+ return _json_text(content) if content else ""
137
+
138
+
139
+ def _mcp_content(item: dict[str, Any]) -> str | None:
140
+ """The result's text, else the error's message (an item that failed before any result)."""
141
+ result = item.get("result")
142
+ if isinstance(result, dict):
143
+ return _result_text(result)
144
+ error = item.get("error")
145
+ message = error.get("message") if isinstance(error, dict) else None
146
+ if isinstance(message, str):
147
+ return message
148
+ return None if error is None else _text(error)
149
+
150
+
151
+ def nested_result_content(item: dict[str, Any]) -> str | None:
152
+ """The observation content of one nested item: its MCP result or command output."""
153
+ if item.get("type") == "CommandExecution":
154
+ output = item.get("aggregated_output")
155
+ return output if isinstance(output, str) else None
156
+ return _mcp_content(item)
157
+
158
+
159
+ __all__ = [
160
+ "COMMAND_TOOL_NAME",
161
+ "NESTED_CALL_ITEM_TYPES",
162
+ "VIA_EXEC",
163
+ "nested_result_content",
164
+ "nested_tool_call",
165
+ ]
@@ -0,0 +1,113 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """edges.jsonl: one line per RAW session record — the uuid-keyed skeleton.
4
+
5
+ Pure functions over already-parsed record dicts; no harbor, no I/O. The
6
+ corpus contract (docs/CONTRACT.md §corpus layout) fixes the line shape:
7
+
8
+ {uuid, parent_uuid, message_id, type, ts, is_sidechain,
9
+ is_compact_summary, source_file, tool_use_ids: [..]}
10
+
11
+ ``tool_use_ids`` carries the ids of ``tool_use`` blocks for assistant
12
+ records and the ``tool_use_id`` of ``tool_result`` blocks for user records
13
+ — the join key back to ATIF ``tool_calls`` / ``observation.source_call_ids``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+ #: Stable key order for one edges.jsonl line (contract-fixed shape).
22
+ EDGE_FIELDS: tuple[str, ...] = (
23
+ "uuid",
24
+ "parent_uuid",
25
+ "message_id",
26
+ "type",
27
+ "ts",
28
+ "is_sidechain",
29
+ "is_compact_summary",
30
+ "source_file",
31
+ "tool_use_ids",
32
+ )
33
+
34
+
35
+ def _tool_use_ids(record: dict[str, Any]) -> list[str]:
36
+ """Extract tool_use ids (assistant) or tool_result ids (user) in block order."""
37
+ message = record.get("message")
38
+ if not isinstance(message, dict):
39
+ return []
40
+ content = message.get("content")
41
+ if not isinstance(content, list):
42
+ return []
43
+
44
+ record_type = record.get("type")
45
+ ids: list[str] = []
46
+ for block in content:
47
+ if not isinstance(block, dict):
48
+ continue
49
+ if record_type == "assistant" and block.get("type") == "tool_use":
50
+ block_id = block.get("id")
51
+ if isinstance(block_id, str) and block_id:
52
+ ids.append(block_id)
53
+ elif record_type == "user" and block.get("type") == "tool_result":
54
+ block_id = block.get("tool_use_id")
55
+ if isinstance(block_id, str) and block_id:
56
+ ids.append(block_id)
57
+ return ids
58
+
59
+
60
+ def edge_from_record(record: dict[str, Any], source_file: str) -> dict[str, Any]:
61
+ """Project one raw record onto the contract's edge shape.
62
+
63
+ ``source_file`` is the record's origin path RELATIVE to the session
64
+ JSONL's parent directory (e.g. ``<sid>.jsonl`` or
65
+ ``<sid>/subagents/workflows/wf_x/agent-y.jsonl``).
66
+ """
67
+ message = record.get("message")
68
+ message_id = message.get("id") if isinstance(message, dict) else None
69
+ return {
70
+ "uuid": record.get("uuid"),
71
+ "parent_uuid": record.get("parentUuid"),
72
+ "message_id": message_id if isinstance(message_id, str) else None,
73
+ "type": record.get("type"),
74
+ "ts": record.get("timestamp"),
75
+ "is_sidechain": bool(record.get("isSidechain", False)),
76
+ "is_compact_summary": bool(record.get("isCompactSummary", False)),
77
+ "source_file": source_file,
78
+ "tool_use_ids": _tool_use_ids(record),
79
+ }
80
+
81
+
82
+ def build_edges(records: list[tuple[dict[str, Any], str]]) -> list[dict[str, Any]]:
83
+ """Build all edges from ``(record, source_file)`` pairs.
84
+
85
+ Ordered deterministically by ``(ts, uuid)``, with missing values sorting
86
+ first as empty strings.
87
+ """
88
+ edges = [edge_from_record(record, source_file) for record, source_file in records]
89
+ try:
90
+ edges.sort(key=lambda e: (e["ts"] or "", e["uuid"] or ""))
91
+ except TypeError:
92
+ # A `timestamp` or `uuid` that is not a string (an object, a list, a number beside a
93
+ # string) cannot be compared with the others; the fuzzer found the `TypeError` this
94
+ # raised. Such values sort as missing ones do. Only an order that raised takes this
95
+ # branch, so every input that converted before keeps its exact order.
96
+ edges.sort(key=lambda e: (_sort_text(e["ts"]), _sort_text(e["uuid"])))
97
+ return edges
98
+
99
+
100
+ def _sort_text(value: object) -> str:
101
+ """A sort key part: the string itself, or ``""`` for a missing or non-string value."""
102
+ return value if isinstance(value, str) else ""
103
+
104
+
105
+ def edge_to_jsonl_line(edge: dict[str, Any]) -> str:
106
+ """Serialize one edge as a compact, key-order-stable JSONL line."""
107
+ ordered = {key: edge.get(key) for key in EDGE_FIELDS}
108
+ return json.dumps(ordered, separators=(",", ":"))
109
+
110
+
111
+ def edges_jsonl_lines(records: list[tuple[dict[str, Any], str]]) -> list[str]:
112
+ """``build_edges`` + serialization: the ready-to-write edges.jsonl body."""
113
+ return [edge_to_jsonl_line(edge) for edge in build_edges(records)]