arctx 0.2.0b2__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 (67) hide show
  1. arctx/__init__.py +48 -0
  2. arctx/core/__init__.py +43 -0
  3. arctx/core/_json.py +28 -0
  4. arctx/core/append.py +46 -0
  5. arctx/core/cuts.py +61 -0
  6. arctx/core/graph_view.py +26 -0
  7. arctx/core/ids.py +32 -0
  8. arctx/core/run/__init__.py +5 -0
  9. arctx/core/run/anchor.py +40 -0
  10. arctx/core/run/attach.py +49 -0
  11. arctx/core/run/cut.py +50 -0
  12. arctx/core/run/dump.py +174 -0
  13. arctx/core/run/handle.py +163 -0
  14. arctx/core/run/outcomes.py +26 -0
  15. arctx/core/run/trace.py +63 -0
  16. arctx/core/run/transition.py +89 -0
  17. arctx/core/run/view.py +34 -0
  18. arctx/core/run_graph.py +241 -0
  19. arctx/core/schema/__init__.py +28 -0
  20. arctx/core/schema/graph.py +40 -0
  21. arctx/core/schema/payloads.py +288 -0
  22. arctx/core/schema/requirements.py +22 -0
  23. arctx/core/schema/snapshots.py +21 -0
  24. arctx/core/schema/work.py +79 -0
  25. arctx/core/schema/work_helpers.py +297 -0
  26. arctx/core/sync/__init__.py +18 -0
  27. arctx/core/sync/local.py +405 -0
  28. arctx/core/sync/records.py +88 -0
  29. arctx/core/sync/shared_store.py +65 -0
  30. arctx/core/types.py +30 -0
  31. arctx/ext/__init__.py +83 -0
  32. arctx/ext/base.py +108 -0
  33. arctx/ext/command/__init__.py +49 -0
  34. arctx/ext/command/payloads.py +77 -0
  35. arctx/ext/command/verbs/__init__.py +1 -0
  36. arctx/ext/command/verbs/run.py +135 -0
  37. arctx/ext/enabled.py +73 -0
  38. arctx/ext/git/__init__.py +158 -0
  39. arctx/ext/git/events.py +132 -0
  40. arctx/ext/git/helpers/__init__.py +1 -0
  41. arctx/ext/git/helpers/attach.py +93 -0
  42. arctx/ext/git/helpers/finish.py +353 -0
  43. arctx/ext/git/helpers/repo.py +299 -0
  44. arctx/ext/git/helpers/session.py +180 -0
  45. arctx/ext/git/helpers/start.py +120 -0
  46. arctx/ext/git/payloads.py +288 -0
  47. arctx/ext/git/queries.py +44 -0
  48. arctx/ext/git/verbs/__init__.py +1 -0
  49. arctx/ext/git/verbs/_forward_transition.py +244 -0
  50. arctx/ext/git/verbs/cherry_pick.py +168 -0
  51. arctx/ext/git/verbs/commit.py +92 -0
  52. arctx/ext/git/verbs/merge.py +178 -0
  53. arctx/ext/git/verbs/reset.py +172 -0
  54. arctx/ext/git/verbs/revert.py +205 -0
  55. arctx/ext/git/verbs/rewrite.py +105 -0
  56. arctx/ext/git/verbs/verify.py +169 -0
  57. arctx/paths.py +149 -0
  58. arctx/payload_builder.py +153 -0
  59. arctx/session/__init__.py +157 -0
  60. arctx/storage/__init__.py +14 -0
  61. arctx/storage/_cache.py +103 -0
  62. arctx/storage/base.py +42 -0
  63. arctx/storage/jsonl.py +385 -0
  64. arctx/storage/sqlite.py +355 -0
  65. arctx-0.2.0b2.dist-info/METADATA +48 -0
  66. arctx-0.2.0b2.dist-info/RECORD +67 -0
  67. arctx-0.2.0b2.dist-info/WHEEL +4 -0
arctx/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """arctx: records the process of optimization and problem-solving."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from arctx.core.graph_view import GraphView
6
+ from arctx.core.run import RunHandle
7
+ from arctx.core.run import init as _core_init
8
+ from arctx.core.run_graph import RunGraph
9
+ from arctx.core.schema import (
10
+ CutPayload,
11
+ Node,
12
+ NodePayload,
13
+ Payload,
14
+ PayloadBase,
15
+ Requirement,
16
+ TraceContext,
17
+ Transition,
18
+ TransitionPayload,
19
+ register_payload_class,
20
+ )
21
+ from arctx.core.types import (
22
+ TargetKind,
23
+ )
24
+
25
+ __version__ = "0.2.0b2"
26
+
27
+
28
+ def init(requirement: Requirement, *, run_id: str | None = None) -> RunHandle:
29
+ """Create a core run handle without enabling extensions."""
30
+ return _core_init(requirement, run_id=run_id)
31
+
32
+ __all__ = [
33
+ "CutPayload",
34
+ "GraphView",
35
+ "Node",
36
+ "NodePayload",
37
+ "Payload",
38
+ "PayloadBase",
39
+ "Requirement",
40
+ "RunGraph",
41
+ "RunHandle",
42
+ "TargetKind",
43
+ "TraceContext",
44
+ "Transition",
45
+ "TransitionPayload",
46
+ "init",
47
+ "register_payload_class",
48
+ ]
arctx/core/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Core graph model."""
2
+
3
+ from arctx.core.graph_view import GraphView
4
+ from arctx.core.ids import opaque_id, sequential_id, slugify, timestamp_id
5
+ from arctx.core.run import RunHandle, init
6
+ from arctx.core.run_graph import RunGraph
7
+ from arctx.core.schema import (
8
+ CutPayload,
9
+ Node,
10
+ NodePayload,
11
+ Payload,
12
+ PayloadBase,
13
+ Requirement,
14
+ TraceContext,
15
+ Transition,
16
+ TransitionPayload,
17
+ register_payload_class,
18
+ )
19
+ from arctx.core.types import (
20
+ TargetKind,
21
+ )
22
+
23
+ __all__ = [
24
+ "CutPayload",
25
+ "GraphView",
26
+ "Node",
27
+ "NodePayload",
28
+ "Payload",
29
+ "PayloadBase",
30
+ "Requirement",
31
+ "RunGraph",
32
+ "RunHandle",
33
+ "TargetKind",
34
+ "TraceContext",
35
+ "Transition",
36
+ "TransitionPayload",
37
+ "init",
38
+ "opaque_id",
39
+ "register_payload_class",
40
+ "sequential_id",
41
+ "slugify",
42
+ "timestamp_id",
43
+ ]
arctx/core/_json.py ADDED
@@ -0,0 +1,28 @@
1
+ """Fast JSON helpers with orjson fallback.
2
+
3
+ Used by storage hot paths. CLI output formatting should keep using
4
+ the stdlib `json` module directly for ensure_ascii / indent control.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ try:
9
+ import orjson as _orjson
10
+
11
+ def loads(s: str | bytes) -> object:
12
+ return _orjson.loads(s)
13
+
14
+ def dumps(obj: object) -> str:
15
+ return _orjson.dumps(obj, option=_orjson.OPT_SORT_KEYS).decode("utf-8")
16
+
17
+ HAVE_ORJSON: bool = True
18
+
19
+ except ImportError:
20
+ import json as _json
21
+
22
+ def loads(s: str | bytes) -> object: # type: ignore[misc]
23
+ return _json.loads(s)
24
+
25
+ def dumps(obj: object) -> str: # type: ignore[misc]
26
+ return _json.dumps(obj, ensure_ascii=False, sort_keys=True)
27
+
28
+ HAVE_ORJSON = False
arctx/core/append.py ADDED
@@ -0,0 +1,46 @@
1
+ """Append-only storage batches for concurrent writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal, Union
7
+
8
+ from arctx.core.graph_view import GraphView
9
+ from arctx.core.schema.graph import Node, Transition
10
+ from arctx.core.schema.payloads import PayloadBase
11
+ from arctx.core.schema.work import WorkEvent, WorkSession
12
+
13
+ GraphRecordKind = Literal["node", "transition", "payload", "view"]
14
+ GraphRecord = Union[Node, Transition, PayloadBase, GraphView]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class GraphRecordEnvelope:
19
+ """A graph record plus the table/category it belongs to."""
20
+
21
+ record_kind: GraphRecordKind
22
+ record_id: str
23
+ record: GraphRecord
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AppendBatch:
28
+ """One atomic append unit for a run."""
29
+
30
+ run_id: str
31
+ user_id: str
32
+ work_session_id: str
33
+ records: tuple[GraphRecordEnvelope, ...]
34
+ work_session: WorkSession
35
+ events: tuple[WorkEvent, ...]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class AppendResult:
40
+ """Result returned after an append batch is committed."""
41
+
42
+ event_id: str
43
+ event_seq: int
44
+ record_ids: tuple[str, ...]
45
+ event_ids: tuple[str, ...] = ()
46
+ event_seqs: tuple[int, ...] = ()
arctx/core/cuts.py ADDED
@@ -0,0 +1,61 @@
1
+ """Read-time computation of inactive transitions and nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from arctx.core.run_graph import RunGraph
6
+ from arctx.core.schema.payloads import CutPayload
7
+
8
+
9
+ def _cut_payloads(graph: RunGraph) -> list[CutPayload]:
10
+ return [p for p in graph.payloads.values() if isinstance(p, CutPayload)]
11
+
12
+
13
+ def cut_transition_ids(graph: RunGraph) -> set[str]:
14
+ return {p.target_id for p in _cut_payloads(graph) if p.target_kind == "transition"}
15
+
16
+
17
+ def cut_node_ids(graph: RunGraph) -> set[str]:
18
+ return {p.target_id for p in _cut_payloads(graph) if p.target_kind == "node"}
19
+
20
+
21
+ def _compute_inactive(graph: RunGraph) -> tuple[set[str], set[str]]:
22
+ inactive_transitions: set[str] = set(cut_transition_ids(graph))
23
+ inactive_nodes: set[str] = set(cut_node_ids(graph))
24
+
25
+ frontier_nodes = list(inactive_nodes)
26
+ frontier_transitions = list(inactive_transitions)
27
+
28
+ while frontier_nodes or frontier_transitions:
29
+ while frontier_transitions:
30
+ transition_id = frontier_transitions.pop()
31
+ out = graph.transition_output(transition_id)
32
+ if out and out not in inactive_nodes:
33
+ inactive_nodes.add(out)
34
+ frontier_nodes.append(out)
35
+
36
+ while frontier_nodes:
37
+ node_id = frontier_nodes.pop()
38
+ for transition_id in graph.transitions_from_node(node_id):
39
+ if transition_id not in inactive_transitions:
40
+ inactive_transitions.add(transition_id)
41
+ frontier_transitions.append(transition_id)
42
+
43
+ return inactive_transitions, inactive_nodes
44
+
45
+
46
+ def inactive_transition_ids(graph: RunGraph) -> set[str]:
47
+ inactive_transitions, _ = _compute_inactive(graph)
48
+ return inactive_transitions
49
+
50
+
51
+ def inactive_node_ids(graph: RunGraph) -> set[str]:
52
+ _, inactive_nodes = _compute_inactive(graph)
53
+ return inactive_nodes
54
+
55
+
56
+ def is_active_node(graph: RunGraph, node_id: str) -> bool:
57
+ return node_id not in inactive_node_ids(graph)
58
+
59
+
60
+ def is_inactive_transition(graph: RunGraph, transition_id: str) -> bool:
61
+ return transition_id in inactive_transition_ids(graph)
@@ -0,0 +1,26 @@
1
+ """GraphView: a named label anchored to a root node in RunGraph.
2
+
3
+ The contents of a view are determined at read time by reachability from
4
+ root_node_id via output transitions. No membership sets are stored.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from arctx.core.types import JSONValue, to_jsonable
12
+
13
+
14
+ @dataclass
15
+ class GraphView:
16
+ """A named label for a subgraph rooted at a single node."""
17
+
18
+ view_id: str
19
+ name: str
20
+ root_node_id: str
21
+ metadata: dict[str, JSONValue] = field(default_factory=dict)
22
+
23
+ def to_dict(self) -> dict[str, JSONValue]:
24
+ d = to_jsonable(self)
25
+ assert isinstance(d, dict)
26
+ return d # type: ignore[return-value]
arctx/core/ids.py ADDED
@@ -0,0 +1,32 @@
1
+ """Identifier helpers for run and transition records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+
9
+
10
+ _SAFE_ID = re.compile(r"[^a-zA-Z0-9_.-]+")
11
+
12
+
13
+ def slugify(value: str, fallback: str = "item") -> str:
14
+ """Return a stable filesystem-safe slug."""
15
+ slug = _SAFE_ID.sub("_", value.strip()).strip("._-").lower()
16
+ return slug or fallback
17
+
18
+
19
+ def timestamp_id(prefix: str, now: datetime | None = None) -> str:
20
+ """Return a timestamp-based id."""
21
+ current = now or datetime.now(timezone.utc)
22
+ return f"{slugify(prefix)}_{current.strftime('%Y%m%d_%H%M%S')}"
23
+
24
+
25
+ def sequential_id(prefix: str, index: int, width: int = 4) -> str:
26
+ """Return ids such as ``state_0001``."""
27
+ return f"{slugify(prefix)}_{index:0{width}d}"
28
+
29
+
30
+ def opaque_id(prefix: str) -> str:
31
+ """Return a collision-resistant opaque id with a readable kind prefix."""
32
+ return f"{slugify(prefix)}_{uuid.uuid4().hex}"
@@ -0,0 +1,5 @@
1
+ """Run handle and initialization."""
2
+
3
+ from arctx.core.run.handle import RunHandle, init
4
+
5
+ __all__ = ["RunHandle", "init"]
@@ -0,0 +1,40 @@
1
+ """RunHandle.anchor implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from arctx.core.schema.graph import Node
6
+ from arctx.core.schema.payloads import TransitionPayload
7
+
8
+
9
+ def anchor_impl(
10
+ self,
11
+ from_node_id: str,
12
+ label: str,
13
+ *,
14
+ metadata: dict | None = None,
15
+ user_id: str | None = None,
16
+ work_session_id: str | None = None,
17
+ ) -> Node:
18
+ """Create a lightweight scope anchor node from an existing node.
19
+
20
+ An anchor is a Transition with type="anchor" and a generated output node.
21
+ The output node can then be used as a shared branching point for experiments.
22
+ """
23
+ meta = dict(metadata or {})
24
+ meta.setdefault("kind", "anchor")
25
+ meta.setdefault("label", label)
26
+
27
+ payload = TransitionPayload(
28
+ payload_id="pending",
29
+ target_id="pending",
30
+ type="anchor",
31
+ content={"label": label},
32
+ metadata=meta,
33
+ )
34
+ transition = self.transition(
35
+ [from_node_id],
36
+ payload,
37
+ user_id=user_id,
38
+ work_session_id=work_session_id,
39
+ )
40
+ return self.run_graph.nodes[transition.output_node_id]
@@ -0,0 +1,49 @@
1
+ """RunHandle.attach implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from arctx.core.schema.payloads import PayloadBase
6
+ from arctx.core.run.transition import _clone_payload
7
+
8
+
9
+ def attach_impl(
10
+ self,
11
+ node_id: str,
12
+ payload: PayloadBase,
13
+ *,
14
+ user_id: str | None = None,
15
+ work_session_id: str | None = None,
16
+ ) -> PayloadBase:
17
+ """Attach a node-targeting payload to a node.
18
+
19
+ *payload* must be a node-targeting payload (target_kind="node").
20
+ Returns the attached payload (with a freshly minted payload_id).
21
+ """
22
+ if payload.target_kind != "node":
23
+ raise ValueError(
24
+ f"attach() requires a node-targeting payload "
25
+ f"(target_kind='node'), got {payload.target_kind!r}"
26
+ )
27
+ if node_id not in self.run_graph.nodes:
28
+ raise KeyError(f"unknown node_id: {node_id}")
29
+
30
+ cloned = _clone_payload(payload, self._next_id("pl"), node_id)
31
+ self.run_graph.attach_payload(cloned)
32
+ self.record_work_event(
33
+ user_id=user_id,
34
+ work_session_id=work_session_id,
35
+ event_type="payload_attached",
36
+ target_kind="node",
37
+ target_id=node_id,
38
+ created_records=(cloned.payload_id,),
39
+ summary=_node_payload_summary(cloned),
40
+ )
41
+ return cloned
42
+
43
+
44
+ def _node_payload_summary(payload: PayloadBase) -> str | None:
45
+ for attr in ("type", "text"):
46
+ val = getattr(payload, attr, None)
47
+ if isinstance(val, str) and val:
48
+ return val
49
+ return None
arctx/core/run/cut.py ADDED
@@ -0,0 +1,50 @@
1
+ """RunHandle.cut implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from arctx.core.cuts import cut_node_ids, cut_transition_ids
8
+ from arctx.core.schema.payloads import CutPayload
9
+
10
+
11
+ def cut_impl(
12
+ self,
13
+ target_id: str,
14
+ *,
15
+ target_kind: Literal["node", "transition"],
16
+ reason: str | None = None,
17
+ user_id: str | None = None,
18
+ work_session_id: str | None = None,
19
+ ) -> CutPayload:
20
+ """Append a CutPayload to mark a Node or Transition inactive."""
21
+ if target_kind == "node":
22
+ if target_id not in self.run_graph.nodes:
23
+ raise KeyError(f"unknown node_id: {target_id}")
24
+ if target_id in cut_node_ids(self.run_graph):
25
+ raise ValueError(f"node already cut: {target_id}")
26
+ elif target_kind == "transition":
27
+ if target_id not in self.run_graph.transitions:
28
+ raise KeyError(f"unknown transition_id: {target_id}")
29
+ if target_id in cut_transition_ids(self.run_graph):
30
+ raise ValueError(f"transition already cut: {target_id}")
31
+ else:
32
+ raise ValueError(f"invalid target_kind: {target_kind!r}")
33
+
34
+ cut = CutPayload(
35
+ payload_id=self._next_id("pl"),
36
+ target_id=target_id,
37
+ target_kind=target_kind,
38
+ reason=reason,
39
+ )
40
+ self.run_graph.attach_payload(cut)
41
+ self.record_work_event(
42
+ user_id=user_id,
43
+ work_session_id=work_session_id,
44
+ event_type="cut_added",
45
+ target_kind=target_kind,
46
+ target_id=target_id,
47
+ created_records=(cut.payload_id,),
48
+ summary=reason,
49
+ )
50
+ return cut
arctx/core/run/dump.py ADDED
@@ -0,0 +1,174 @@
1
+ """Dump RunGraph as outline or mermaid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from arctx.core.cuts import inactive_node_ids, inactive_transition_ids
8
+ from arctx.core.run.handle import RunHandle
9
+ from arctx.core.run_graph import RunGraph
10
+ from arctx.core.schema.payloads import CutPayload, NodePayload, TransitionPayload
11
+
12
+
13
+ @dataclass
14
+ class DumpOptions:
15
+ node_id: str | None = None
16
+ depth: int | None = None
17
+ full_payloads: bool = False
18
+ observed_only: bool = False # unused after schema change; kept for CLI compat
19
+ predicted_only: bool = False # unused after schema change; kept for CLI compat
20
+
21
+
22
+ def _truncate(s: str | None, n: int) -> str:
23
+ if not s:
24
+ return ""
25
+ return s if len(s) <= n else s[: n - 1] + "…"
26
+
27
+
28
+ def _node_summary(graph: RunGraph, node_id: str) -> str | None:
29
+ for payload in graph.payloads_for_node(node_id):
30
+ if isinstance(payload, NodePayload):
31
+ text = payload.content.get("text")
32
+ if isinstance(text, str) and text:
33
+ return text
34
+ return payload.type
35
+ return None
36
+
37
+
38
+ def _transition_summary(graph: RunGraph, transition_id: str, full: bool) -> str:
39
+ payloads = graph.payloads_for_transition(transition_id)
40
+ parts = []
41
+ for payload in payloads:
42
+ if isinstance(payload, CutPayload):
43
+ parts.append("✂cut")
44
+ elif isinstance(payload, TransitionPayload):
45
+ parts.append(payload.type)
46
+ if full and payload.content:
47
+ import json
48
+ parts.append(json.dumps(payload.content)[:60])
49
+ else:
50
+ parts.append(payload.payload_type)
51
+ return " ".join(parts) if parts else "transition"
52
+
53
+
54
+ def render_outline(handle: RunHandle, opts: DumpOptions) -> str:
55
+ graph = handle.run_graph
56
+ inactive_nodes = inactive_node_ids(graph)
57
+ inactive_trans = inactive_transition_ids(graph)
58
+ root_id = opts.node_id or handle.root_node_id
59
+
60
+ lines = [
61
+ (
62
+ f"run={handle.run_id} nodes={len(graph.nodes)} "
63
+ f"transitions={len(graph.transitions)}"
64
+ ),
65
+ "",
66
+ ]
67
+ visited_nodes: set[str] = set()
68
+ visited_transitions: set[str] = set()
69
+
70
+ # Count multi-input transitions for joins index.
71
+ multi_input_trans = [
72
+ tid for tid, t in graph.transitions.items() if len(t.input_node_ids) > 1
73
+ ]
74
+
75
+ def emit_node(node_id: str, prefix: str, is_last: bool, depth: int) -> None:
76
+ cut = " ✂" if node_id in inactive_nodes else ""
77
+ connector = "" if depth == 0 else ("└─" if is_last else "├─")
78
+ if node_id in visited_nodes:
79
+ lines.append(f"{prefix}{connector}↻ {node_id}{cut}")
80
+ return
81
+ visited_nodes.add(node_id)
82
+ lines.append(f"{prefix}{connector}{node_id}{cut}")
83
+ note = _node_summary(graph, node_id)
84
+ child_prefix = prefix + (" " if depth == 0 or is_last else "│ ")
85
+ if note:
86
+ lines.append(f"{child_prefix}note: {_truncate(note, 80)}")
87
+ if opts.depth is not None and depth >= opts.depth:
88
+ return
89
+ transition_ids = graph.transitions_from_node(node_id)
90
+ for index, transition_id in enumerate(transition_ids):
91
+ t = graph.transitions[transition_id]
92
+ # Only render as primary if this node is inputs[0].
93
+ if t.input_node_ids and t.input_node_ids[0] != node_id:
94
+ lines.append(
95
+ f"{child_prefix}▸ feeds {transition_id} (@{t.input_node_ids[0]})"
96
+ )
97
+ continue
98
+ emit_transition(
99
+ transition_id,
100
+ child_prefix,
101
+ index == len(transition_ids) - 1,
102
+ depth + 1,
103
+ )
104
+
105
+ def emit_transition(transition_id: str, prefix: str, is_last: bool, depth: int) -> None:
106
+ t = graph.transitions[transition_id]
107
+ summary = _transition_summary(graph, transition_id, opts.full_payloads)
108
+ cut = " ✂" if transition_id in inactive_trans else ""
109
+ connector = "└─" if is_last else "├─"
110
+ if transition_id in visited_transitions:
111
+ lines.append(f"{prefix}{connector}↻ {transition_id}{cut}")
112
+ return
113
+ visited_transitions.add(transition_id)
114
+ # Show extra inputs inline.
115
+ extras = ""
116
+ if len(t.input_node_ids) > 1:
117
+ extras = " " + " ".join(f"(+{n})" for n in t.input_node_ids[1:])
118
+ lines.append(f"{prefix}{connector}→ {transition_id}{cut}{extras} {summary}")
119
+ child_prefix = prefix + (" " if is_last else "│ ")
120
+ if t.output_node_id:
121
+ emit_node(t.output_node_id, child_prefix, True, depth + 1)
122
+
123
+ emit_node(root_id, "", True, 0)
124
+
125
+ if len(multi_input_trans) >= 3:
126
+ lines.append("")
127
+ lines.append("joins:")
128
+ for tid in multi_input_trans:
129
+ t = graph.transitions[tid]
130
+ lines.append(f" {tid}: inputs={list(t.input_node_ids)}")
131
+
132
+ return "\n".join(lines)
133
+
134
+
135
+ def render_mermaid(handle: RunHandle, opts: DumpOptions) -> str:
136
+ graph = handle.run_graph
137
+ inactive_nodes = inactive_node_ids(graph)
138
+ inactive_trans = inactive_transition_ids(graph)
139
+ lines = ["```mermaid", "flowchart TD"]
140
+ for node_id in graph.nodes:
141
+ label = "State"
142
+ note = _node_summary(graph, node_id)
143
+ if note:
144
+ label = _truncate(note, 36).replace('"', "'")
145
+ is_root = node_id == handle.root_node_id
146
+ cls = "root" if is_root else "cut" if node_id in inactive_nodes else "state"
147
+ lines.append(f' {node_id}["{label}"]')
148
+ if cls != "state":
149
+ lines.append(f" class {node_id} {cls}")
150
+
151
+ for transition_id, t in graph.transitions.items():
152
+ summary = _transition_summary(graph, transition_id, False)
153
+ summary = _truncate(summary, 42).replace('"', "'")
154
+ is_cut = transition_id in inactive_trans
155
+ if t.output_node_id:
156
+ for inp in t.input_node_ids:
157
+ lines.append(f' {inp} -->|"{summary}"| {t.output_node_id}')
158
+ if is_cut:
159
+ lines.append(f" class {transition_id} cut")
160
+
161
+ if inactive_nodes:
162
+ lines.append(f" class {','.join(sorted(inactive_nodes))} cut")
163
+ lines.append(" classDef cut stroke:#999,stroke-dasharray: 4 4,color:#999")
164
+ lines.append(" classDef root fill:#ffcc00,stroke:#1d4ed8")
165
+ lines.append("```")
166
+ return "\n".join(lines)
167
+
168
+
169
+ def dump(handle: RunHandle, fmt: str, opts: DumpOptions) -> str:
170
+ if fmt == "outline":
171
+ return render_outline(handle, opts)
172
+ if fmt == "mermaid":
173
+ return render_mermaid(handle, opts)
174
+ raise ValueError(f"unknown dump format: {fmt!r}")