psystack 0.1.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 (149) hide show
  1. psystack/__init__.py +3 -0
  2. psystack/__main__.py +5 -0
  3. psystack/adapters/__init__.py +0 -0
  4. psystack/adapters/f1/__init__.py +0 -0
  5. psystack/adapters/f1/controllers.py +56 -0
  6. psystack/adapters/f1/degrade.py +31 -0
  7. psystack/adapters/f1/env.py +48 -0
  8. psystack/adapters/f1/factory.py +182 -0
  9. psystack/adapters/f1/live_viewer.py +143 -0
  10. psystack/adapters/f1/planner.py +39 -0
  11. psystack/adapters/f1/signals.py +353 -0
  12. psystack/adapters/f1/world_model.py +75 -0
  13. psystack/adapters/registry.py +35 -0
  14. psystack/cli/__init__.py +0 -0
  15. psystack/cli/app.py +21 -0
  16. psystack/cli/version_check.py +32 -0
  17. psystack/cli/wizard/__init__.py +3 -0
  18. psystack/cli/wizard/discovery.py +65 -0
  19. psystack/cli/wizard/models.py +38 -0
  20. psystack/cli/wizard/questions.py +174 -0
  21. psystack/cli/wizard/review.py +54 -0
  22. psystack/cli/wizard/service.py +181 -0
  23. psystack/core/__init__.py +0 -0
  24. psystack/core/config.py +77 -0
  25. psystack/core/contracts.py +124 -0
  26. psystack/core/signal_schema.py +54 -0
  27. psystack/evaluation/__init__.py +0 -0
  28. psystack/evaluation/metrics/__init__.py +22 -0
  29. psystack/evaluation/metrics/offtrack.py +30 -0
  30. psystack/evaluation/metrics/prediction_error.py +71 -0
  31. psystack/evaluation/metrics/progress.py +22 -0
  32. psystack/evaluation/metrics/reward.py +22 -0
  33. psystack/evaluation/metrics/survival.py +22 -0
  34. psystack/models/__init__.py +42 -0
  35. psystack/models/case.py +30 -0
  36. psystack/models/comparison.py +30 -0
  37. psystack/models/episode.py +82 -0
  38. psystack/models/evaluation_result.py +51 -0
  39. psystack/models/event.py +40 -0
  40. psystack/models/evidence.py +18 -0
  41. psystack/models/explanation.py +23 -0
  42. psystack/models/isolation.py +35 -0
  43. psystack/models/manifest.py +24 -0
  44. psystack/models/metric.py +14 -0
  45. psystack/models/project.py +25 -0
  46. psystack/models/run.py +50 -0
  47. psystack/models/signal.py +14 -0
  48. psystack/models/swap.py +25 -0
  49. psystack/pipeline/__init__.py +0 -0
  50. psystack/pipeline/case_io.py +22 -0
  51. psystack/pipeline/compare/__init__.py +4 -0
  52. psystack/pipeline/compare/decision.py +20 -0
  53. psystack/pipeline/compare/execution.py +50 -0
  54. psystack/pipeline/compare/service.py +95 -0
  55. psystack/pipeline/compare/stats.py +60 -0
  56. psystack/pipeline/compare_module.py +259 -0
  57. psystack/pipeline/context.py +194 -0
  58. psystack/pipeline/episodes.py +109 -0
  59. psystack/pipeline/event_extraction.py +253 -0
  60. psystack/pipeline/events/__init__.py +6 -0
  61. psystack/pipeline/events/config.py +41 -0
  62. psystack/pipeline/events/detection.py +231 -0
  63. psystack/pipeline/events/divergence.py +106 -0
  64. psystack/pipeline/isolation/__init__.py +4 -0
  65. psystack/pipeline/isolation/attribution.py +187 -0
  66. psystack/pipeline/isolation/designs.py +35 -0
  67. psystack/pipeline/isolation/executor.py +60 -0
  68. psystack/pipeline/isolation/planner.py +10 -0
  69. psystack/pipeline/live_update.py +59 -0
  70. psystack/pipeline/metrics_util.py +65 -0
  71. psystack/pipeline/paired_runner.py +185 -0
  72. psystack/pipeline/runner.py +107 -0
  73. psystack/pipeline/stages/__init__.py +22 -0
  74. psystack/pipeline/stages/attribute.py +78 -0
  75. psystack/pipeline/stages/base.py +18 -0
  76. psystack/pipeline/stages/compare.py +37 -0
  77. psystack/pipeline/stages/events.py +53 -0
  78. psystack/pipeline/stages/isolate.py +88 -0
  79. psystack/pipeline/stages/report.py +59 -0
  80. psystack/pipeline/staleness.py +33 -0
  81. psystack/pipeline/state.py +31 -0
  82. psystack/pipeline/workspace.py +177 -0
  83. psystack/reporting/__init__.py +0 -0
  84. psystack/reporting/bundle.py +74 -0
  85. psystack/reporting/evidence.py +28 -0
  86. psystack/reporting/renderers/__init__.py +0 -0
  87. psystack/reporting/renderers/console.py +27 -0
  88. psystack/reporting/renderers/html.py +28 -0
  89. psystack/reporting/renderers/json.py +13 -0
  90. psystack/reporting/templates/investigation_report.html.j2 +85 -0
  91. psystack/reporting/templates/report.html.j2 +99 -0
  92. psystack/reporting/types.py +33 -0
  93. psystack/tui/__init__.py +0 -0
  94. psystack/tui/actions.py +78 -0
  95. psystack/tui/app.py +1188 -0
  96. psystack/tui/detection.py +241 -0
  97. psystack/tui/screens/__init__.py +1 -0
  98. psystack/tui/screens/attribution.py +252 -0
  99. psystack/tui/screens/case_history.py +131 -0
  100. psystack/tui/screens/case_verdict.py +657 -0
  101. psystack/tui/screens/command_palette.py +70 -0
  102. psystack/tui/screens/drawers/__init__.py +1 -0
  103. psystack/tui/screens/drawers/context_drawer.py +90 -0
  104. psystack/tui/screens/drawers/evidence_drawer.py +113 -0
  105. psystack/tui/screens/error_modal.py +54 -0
  106. psystack/tui/screens/investigation.py +686 -0
  107. psystack/tui/screens/run_builder.py +492 -0
  108. psystack/tui/screens/workspace_picker.py +69 -0
  109. psystack/tui/services.py +769 -0
  110. psystack/tui/state.py +137 -0
  111. psystack/tui/styles/app.tcss +224 -0
  112. psystack/tui/views/__init__.py +0 -0
  113. psystack/tui/widgets/__init__.py +0 -0
  114. psystack/tui/widgets/action_bar.py +42 -0
  115. psystack/tui/widgets/artifact_list.py +38 -0
  116. psystack/tui/widgets/artifact_preview.py +34 -0
  117. psystack/tui/widgets/attribution_decision_card.py +55 -0
  118. psystack/tui/widgets/case_bar.py +108 -0
  119. psystack/tui/widgets/causal_sequence.py +73 -0
  120. psystack/tui/widgets/comparability_summary.py +48 -0
  121. psystack/tui/widgets/context_rail.py +69 -0
  122. psystack/tui/widgets/effect_table.py +32 -0
  123. psystack/tui/widgets/event_navigator.py +176 -0
  124. psystack/tui/widgets/explanation_card.py +67 -0
  125. psystack/tui/widgets/falsifier_list.py +73 -0
  126. psystack/tui/widgets/focus_signals_strip.py +22 -0
  127. psystack/tui/widgets/help_overlay.py +85 -0
  128. psystack/tui/widgets/isolation_case_detail.py +67 -0
  129. psystack/tui/widgets/isolation_case_table.py +50 -0
  130. psystack/tui/widgets/live_run_monitor.py +337 -0
  131. psystack/tui/widgets/metric_detail.py +93 -0
  132. psystack/tui/widgets/metric_table.py +71 -0
  133. psystack/tui/widgets/progress_summary.py +300 -0
  134. psystack/tui/widgets/run_config_panel.py +163 -0
  135. psystack/tui/widgets/run_monitor.py +91 -0
  136. psystack/tui/widgets/section_title.py +15 -0
  137. psystack/tui/widgets/signal_timeline.py +206 -0
  138. psystack/tui/widgets/status_badge.py +52 -0
  139. psystack/tui/widgets/step_inspector.py +105 -0
  140. psystack/tui/widgets/tier_indicator.py +44 -0
  141. psystack/tui/widgets/track_map.py +137 -0
  142. psystack/tui/widgets/transport_bar.py +152 -0
  143. psystack/tui/widgets/verdict_strip.py +103 -0
  144. psystack-0.1.0.dist-info/METADATA +42 -0
  145. psystack-0.1.0.dist-info/RECORD +149 -0
  146. psystack-0.1.0.dist-info/WHEEL +5 -0
  147. psystack-0.1.0.dist-info/entry_points.txt +5 -0
  148. psystack-0.1.0.dist-info/licenses/LICENSE +21 -0
  149. psystack-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,206 @@
1
+ """Signal timeline — character-based sparkline with event markers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from rich.text import Text
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Vertical
10
+ from textual.reactive import reactive, var
11
+ from textual.widgets import Static
12
+
13
+ from psystack.models.event import Event
14
+
15
+ # Unicode block sparkline characters (proper visual bars)
16
+ _SPARK_CHARS = " ▁▂▃▄▅▆▇█"
17
+
18
+
19
+ class TimelineMode(str, Enum):
20
+ """Display modes for the signal timeline."""
21
+
22
+ DIVERGENCE = "divergence"
23
+ SIGNAL_COMPARE = "signal_compare"
24
+
25
+
26
+ def _sparkline(values: list[float], width: int) -> str:
27
+ """Render a sparkline from values into a fixed-width string."""
28
+ if not values:
29
+ return " " * width
30
+
31
+ mn = min(values)
32
+ mx = max(values)
33
+ rng = mx - mn if mx != mn else 1.0
34
+
35
+ # Downsample or upsample to fit width
36
+ result = []
37
+ for i in range(width):
38
+ idx = int(i * len(values) / width)
39
+ idx = min(idx, len(values) - 1)
40
+ normalized = (values[idx] - mn) / rng
41
+ char_idx = int(normalized * (len(_SPARK_CHARS) - 1))
42
+ result.append(_SPARK_CHARS[char_idx])
43
+ return "".join(result)
44
+
45
+
46
+ def _event_markers(events: list[Event], max_step: int, width: int) -> str:
47
+ """Render event position markers aligned to sparkline width."""
48
+ if not events or max_step == 0:
49
+ return "─" * width
50
+ chars = ["─"] * width
51
+ for evt in events:
52
+ pos = int(evt.step * width / max_step)
53
+ pos = min(pos, width - 1)
54
+ if evt.severity == "critical":
55
+ chars[pos] = "●"
56
+ elif evt.severity == "warning":
57
+ chars[pos] = "◆"
58
+ else:
59
+ chars[pos] = "·"
60
+ return "".join(chars)
61
+
62
+
63
+ class SignalTimeline(Vertical):
64
+ """Character-based timeline showing divergence score over time."""
65
+
66
+ current_step: reactive[int] = reactive(0)
67
+ timeline_mode: var[TimelineMode] = var(TimelineMode.DIVERGENCE)
68
+
69
+ DEFAULT_CSS = """
70
+ SignalTimeline {
71
+ height: auto;
72
+ max-height: 10;
73
+ border: round $panel;
74
+ padding: 1 2;
75
+ }
76
+ SignalTimeline .st-sparkline {
77
+ color: $accent;
78
+ }
79
+ SignalTimeline .st-events {
80
+ color: $error;
81
+ }
82
+ SignalTimeline .st-cursor {
83
+ color: $warning;
84
+ }
85
+ """
86
+
87
+ def __init__(self, **kwargs) -> None:
88
+ super().__init__(**kwargs)
89
+ self._scores: list[float] = []
90
+ self._events: list[Event] = []
91
+ self._max_step: int = 0
92
+ self._active_signal_name: str = ""
93
+ self._values_a: list[float] = []
94
+ self._values_b: list[float] = []
95
+ self._window_range: tuple[int, int] | None = None
96
+
97
+ def compose(self) -> ComposeResult:
98
+ self.border_title = "Timeline"
99
+ yield Static("", id="st-spark-a", classes="st-sparkline")
100
+ yield Static("", id="st-spark-b", classes="st-sparkline")
101
+ yield Static("", id="st-event-markers", classes="st-events")
102
+ yield Static("", id="st-cursor-line", classes="st-cursor")
103
+
104
+ def set_data(
105
+ self,
106
+ scores: list[float],
107
+ events: list[Event],
108
+ max_step: int,
109
+ values_a: list[float] | None = None,
110
+ values_b: list[float] | None = None,
111
+ active_signal_name: str = "",
112
+ window_range: tuple[int, int] | None = None,
113
+ ) -> None:
114
+ self._scores = scores
115
+ self._events = events
116
+ self._max_step = max_step
117
+ self._values_a = values_a or []
118
+ self._values_b = values_b or []
119
+ self._active_signal_name = active_signal_name
120
+ self._window_range = window_range
121
+ self._refresh_display()
122
+
123
+ def cycle_mode(self) -> None:
124
+ """Toggle between divergence and signal_compare modes."""
125
+ if self.timeline_mode == TimelineMode.DIVERGENCE:
126
+ self.timeline_mode = TimelineMode.SIGNAL_COMPARE
127
+ else:
128
+ self.timeline_mode = TimelineMode.DIVERGENCE
129
+
130
+ def watch_timeline_mode(self, value: TimelineMode) -> None:
131
+ self._refresh_display()
132
+
133
+ def watch_current_step(self, step: int) -> None:
134
+ self._render_cursor(step)
135
+
136
+ def _refresh_display(self) -> None:
137
+ width = max(self.size.width - 4, 20) if self.size.width > 0 else 60
138
+
139
+ # Update border title with mode label
140
+ spark_a = self.query_one("#st-spark-a", Static)
141
+ spark_b = self.query_one("#st-spark-b", Static)
142
+
143
+ if self.timeline_mode == TimelineMode.DIVERGENCE:
144
+ # Single sparkline from divergence scores
145
+ title_parts = ["Timeline"]
146
+ title_parts.append("[divergence]")
147
+ if self._active_signal_name:
148
+ title_parts.append(self._active_signal_name)
149
+ if self._window_range:
150
+ lo, hi = self._window_range
151
+ title_parts.append(f"[{lo}..{hi}]")
152
+ self.border_title = " \u2014 ".join(title_parts)
153
+ spark_text = Text()
154
+ spark_text.append("S ", style="bold dim")
155
+ spark_text.append(_sparkline(self._scores, width), style="")
156
+ spark_a.update(spark_text)
157
+ spark_b.update("")
158
+ else:
159
+ # Dual sparklines A/B (signal_compare mode)
160
+ signal_label = self._active_signal_name or "signal"
161
+ title_parts = ["Timeline", f"[{signal_label}]"]
162
+ if self._window_range:
163
+ lo, hi = self._window_range
164
+ title_parts.append(f"[{lo}..{hi}]")
165
+ self.border_title = " \u2014 ".join(title_parts)
166
+ if self._values_a:
167
+ text_a = Text()
168
+ text_a.append("A ", style="bold dim")
169
+ text_a.append(_sparkline(self._values_a, width), style="")
170
+ spark_a.update(text_a)
171
+ else:
172
+ text_a = Text()
173
+ text_a.append("S ", style="bold dim")
174
+ text_a.append(_sparkline(self._scores, width), style="")
175
+ spark_a.update(text_a)
176
+ if self._values_b:
177
+ text_b = Text()
178
+ text_b.append("B ", style="bold dim")
179
+ text_b.append(_sparkline(self._values_b, width), style="")
180
+ spark_b.update(text_b)
181
+ else:
182
+ spark_b.update("")
183
+
184
+ # Event markers
185
+ markers = self.query_one("#st-event-markers", Static)
186
+ marker_text = Text()
187
+ marker_text.append("E ", style="bold dim")
188
+ marker_text.append(_event_markers(self._events, self._max_step, width), style="")
189
+ markers.update(marker_text)
190
+
191
+ self._render_cursor(self.current_step)
192
+
193
+ def _render_cursor(self, step: int) -> None:
194
+ width = max(self.size.width - 4, 20) if self.size.width > 0 else 60
195
+ cursor = self.query_one("#st-cursor-line", Static)
196
+ if self._max_step > 0:
197
+ pos = int(step * width / self._max_step)
198
+ pos = min(pos, width - 1)
199
+ line = "╌" * pos + "▼" + "╌" * (width - pos - 1)
200
+ cursor_text = Text()
201
+ cursor_text.append(" ", style="")
202
+ cursor_text.append(line, style="")
203
+ cursor_text.append(f" t={step}", style="bold")
204
+ cursor.update(cursor_text)
205
+ else:
206
+ cursor.update("")
@@ -0,0 +1,52 @@
1
+ """Status badge with color variants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import Static
6
+
7
+ VARIANT_CLASSES = {
8
+ "completed": "status-completed",
9
+ "running": "status-running",
10
+ "failed": "status-failed",
11
+ "skipped": "status-skipped",
12
+ "default": "status-default",
13
+ }
14
+
15
+
16
+ class StatusBadge(Static):
17
+ DEFAULT_CSS = """
18
+ StatusBadge {
19
+ padding: 0 1;
20
+ margin: 0 1;
21
+ min-width: 8;
22
+ text-align: center;
23
+ }
24
+ StatusBadge.status-completed {
25
+ background: $success;
26
+ color: $text;
27
+ }
28
+ StatusBadge.status-running {
29
+ background: $warning;
30
+ color: $text;
31
+ }
32
+ StatusBadge.status-failed {
33
+ background: $error;
34
+ color: $text;
35
+ }
36
+ StatusBadge.status-skipped {
37
+ color: $text-muted;
38
+ }
39
+ StatusBadge.status-default {
40
+ color: $text;
41
+ }
42
+ """
43
+
44
+ def __init__(self, label: str, variant: str = "default", **kwargs: object) -> None:
45
+ super().__init__(label, **kwargs)
46
+ self.set_variant(variant)
47
+
48
+ def set_variant(self, variant: str) -> None:
49
+ for cls in VARIANT_CLASSES.values():
50
+ self.remove_class(cls)
51
+ css_class = VARIANT_CLASSES.get(variant, "status-default")
52
+ self.add_class(css_class)
@@ -0,0 +1,105 @@
1
+ """Step inspector — shows A/B values at the current step with delta highlighting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.text import Text
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Vertical
10
+ from textual.widgets import Static
11
+
12
+ if TYPE_CHECKING:
13
+ from psystack.core.signal_schema import SignalSchema
14
+
15
+ from psystack.models.event import Event
16
+
17
+
18
+ class StepInspector(Vertical):
19
+ """Bottom-right panel showing per-step A/B signal values with delta highlighting."""
20
+
21
+ DEFAULT_CSS = """
22
+ StepInspector {
23
+ width: 100%;
24
+ height: 1fr;
25
+ border: round $panel;
26
+ padding: 1 1;
27
+ }
28
+ StepInspector .si-content {
29
+ color: $text-muted;
30
+ }
31
+ """
32
+
33
+ def __init__(self, **kwargs: object) -> None:
34
+ super().__init__(**kwargs)
35
+ self._schema: SignalSchema | None = None
36
+
37
+ def compose(self) -> ComposeResult:
38
+ self.border_title = "Step Inspector"
39
+ yield Static("Select a step to inspect", id="si-content", classes="si-content")
40
+
41
+ def set_schema(self, schema: "SignalSchema | None") -> None:
42
+ self._schema = schema
43
+
44
+ def update_step(
45
+ self,
46
+ step: int,
47
+ signals_a: dict[str, float] | None = None,
48
+ signals_b: dict[str, float] | None = None,
49
+ event: Event | None = None,
50
+ hypothesis: str | None = None,
51
+ ) -> None:
52
+ content = self.query_one("#si-content", Static)
53
+ text = Text()
54
+ text.append(f"step {step}", style="bold")
55
+ text.append("\n")
56
+
57
+ # Show event info first if present (most important context)
58
+ if event:
59
+ text.append(event.type.replace("_", " "), style="")
60
+ text.append(f" score={event.score:.3f}\n", style="dim")
61
+
62
+ # Signal deltas with directional arrows
63
+ if signals_a and signals_b:
64
+ keys: list[str] = []
65
+ if self._schema:
66
+ for group_name in self._schema.group_order:
67
+ for sig_def in self._schema.groups.get(group_name, []):
68
+ keys.append(sig_def.name)
69
+ schema_keys = set(self._schema.signal_names())
70
+ extra = sorted(set(signals_a.keys()) | set(signals_b.keys()) - schema_keys)
71
+ keys.extend(extra)
72
+ else:
73
+ keys = sorted(set(signals_a) | set(signals_b))
74
+
75
+ for key in keys:
76
+ va = signals_a.get(key, 0.0)
77
+ vb = signals_b.get(key, 0.0)
78
+ delta = vb - va
79
+ threshold = 0.1
80
+ if self._schema:
81
+ sig_def = self._schema.get_def(key)
82
+ if sig_def:
83
+ threshold = sig_def.delta_threshold
84
+
85
+ display_key = key[:10]
86
+ text.append(f"{display_key:10s} ", style="dim")
87
+
88
+ if abs(delta) > threshold:
89
+ arrow = "\u2191" if delta > 0 else "\u2193"
90
+ sign = "+" if delta >= 0 else ""
91
+ text.append(f"{arrow} {sign}{delta:.3f}", style="bold red")
92
+ elif abs(delta) > threshold * 0.5:
93
+ arrow = "\u2191" if delta > 0 else "\u2193"
94
+ sign = "+" if delta >= 0 else ""
95
+ text.append(f"{arrow} {sign}{delta:.3f}", style="yellow")
96
+ else:
97
+ sign = "+" if delta >= 0 else ""
98
+ text.append(f" {sign}{delta:.3f}", style="dim")
99
+ text.append("\n")
100
+
101
+ if hypothesis:
102
+ text.append("\n")
103
+ text.append(f"\u2192 {hypothesis}\n", style="italic")
104
+
105
+ content.update(text)
@@ -0,0 +1,44 @@
1
+ """Tier indicator — displays attribution tier with visual differentiation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ from textual.widgets import Static
8
+
9
+ _TIER_MESSAGES = {
10
+ "tier_0": "Correlational only — no component isolation performed",
11
+ "tier_1": "Component-isolated — swap tests narrow the cause",
12
+ "tier_2": "Counterfactual — cross-validated with swap experiments",
13
+ "tier_3": "Mechanistic — causal chain fully traced",
14
+ }
15
+
16
+ _TIER_STYLES = {
17
+ "tier_0": ("bold red", "T0"),
18
+ "tier_1": ("bold yellow", "T1"),
19
+ "tier_2": ("bold green", "T2"),
20
+ "tier_3": ("bold cyan", "T3"),
21
+ }
22
+
23
+
24
+ class TierIndicator(Static):
25
+ """Shows the current attribution tier with colored badge and context."""
26
+
27
+ DEFAULT_CSS = """
28
+ TierIndicator {
29
+ height: auto;
30
+ padding: 1;
31
+ background: $boost;
32
+ }
33
+ """
34
+
35
+ def set_tier(self, tier: str) -> None:
36
+ msg = _TIER_MESSAGES.get(tier, f"Unknown tier: {tier}")
37
+ style, badge = _TIER_STYLES.get(tier, ("bold", "T?"))
38
+ label = tier.replace("_", " ").title()
39
+
40
+ text = Text()
41
+ text.append(f" {badge} ", style=f"{style} reverse")
42
+ text.append(f" {label}: ", style="bold")
43
+ text.append(msg)
44
+ self.update(text)
@@ -0,0 +1,137 @@
1
+ """Braille-rasterized track map for progress visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+
8
+ # Braille dot positions: (col_offset, row_offset) -> bit index
9
+ # Unicode braille: U+2800 + bitmask
10
+ # Dot layout per cell (2 cols x 4 rows):
11
+ # (0,0)=0x01 (1,0)=0x08
12
+ # (0,1)=0x02 (1,1)=0x10
13
+ # (0,2)=0x04 (1,2)=0x20
14
+ # (0,3)=0x40 (1,3)=0x80
15
+ _DOT_MAP = {
16
+ (0, 0): 0x01, (1, 0): 0x08,
17
+ (0, 1): 0x02, (1, 1): 0x10,
18
+ (0, 2): 0x04, (1, 2): 0x20,
19
+ (0, 3): 0x40, (1, 3): 0x80,
20
+ }
21
+
22
+
23
+ def _bresenham(x0: int, y0: int, x1: int, y1: int) -> list[tuple[int, int]]:
24
+ """Integer Bresenham line rasterization."""
25
+ points: list[tuple[int, int]] = []
26
+ dx = abs(x1 - x0)
27
+ dy = abs(y1 - y0)
28
+ sx = 1 if x0 < x1 else -1
29
+ sy = 1 if y0 < y1 else -1
30
+ err = dx - dy
31
+ while True:
32
+ points.append((x0, y0))
33
+ if x0 == x1 and y0 == y1:
34
+ break
35
+ e2 = 2 * err
36
+ if e2 > -dy:
37
+ err -= dy
38
+ x0 += sx
39
+ if e2 < dx:
40
+ err += dx
41
+ y0 += sy
42
+ return points
43
+
44
+
45
+ class BrailleTrackRaster:
46
+ """Pre-rasterize a track shape into a braille pixel grid.
47
+
48
+ Each pixel records its progress value (0.0–1.0) along the track.
49
+ Rendering sweeps the grid, coloring pixels before the cutoff bright
50
+ and after it dim.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ points: list[tuple[float, float]],
56
+ cols: int = 25,
57
+ rows: int = 10,
58
+ ) -> None:
59
+ self.cols = cols
60
+ self.rows = rows
61
+ # Pixel grid dimensions (2 dots per col, 4 dots per row)
62
+ self.px_w = cols * 2
63
+ self.px_h = rows * 4
64
+
65
+ # pixel_progress: maps (px, py) -> progress float (0.0–1.0)
66
+ self.pixel_progress: dict[tuple[int, int], float] = {}
67
+
68
+ if len(points) < 2:
69
+ return
70
+
71
+ # Normalize points to pixel grid, preserving aspect ratio
72
+ xs = [p[0] for p in points]
73
+ ys = [p[1] for p in points]
74
+ min_x, max_x = min(xs), max(xs)
75
+ min_y, max_y = min(ys), max(ys)
76
+ range_x = max_x - min_x or 1.0
77
+ range_y = max_y - min_y or 1.0
78
+
79
+ # Preserve aspect ratio
80
+ scale = min((self.px_w - 1) / range_x, (self.px_h - 1) / range_y)
81
+ # Center in the grid
82
+ off_x = (self.px_w - 1 - range_x * scale) / 2
83
+ off_y = (self.px_h - 1 - range_y * scale) / 2
84
+
85
+ def to_px(x: float, y: float) -> tuple[int, int]:
86
+ px = int((x - min_x) * scale + off_x)
87
+ py = int((y - min_y) * scale + off_y)
88
+ return (
89
+ max(0, min(self.px_w - 1, px)),
90
+ max(0, min(self.px_h - 1, py)),
91
+ )
92
+
93
+ # Walk consecutive pairs, rasterize with Bresenham
94
+ n = len(points)
95
+ for i in range(n - 1):
96
+ x0, y0 = to_px(*points[i])
97
+ x1, y1 = to_px(*points[i + 1])
98
+ progress_start = i / (n - 1)
99
+ progress_end = (i + 1) / (n - 1)
100
+ line_pts = _bresenham(x0, y0, x1, y1)
101
+ for j, (px, py) in enumerate(line_pts):
102
+ t = progress_start + (progress_end - progress_start) * (j / max(len(line_pts) - 1, 1))
103
+ if (px, py) not in self.pixel_progress:
104
+ self.pixel_progress[(px, py)] = t
105
+
106
+ def render(
107
+ self,
108
+ progress: float,
109
+ color: str = "cyan",
110
+ dim_color: str = "grey37",
111
+ ) -> Text:
112
+ """Render the track with pixels up to `progress` in color, rest dim."""
113
+ text = Text()
114
+ for row in range(self.rows):
115
+ for col in range(self.cols):
116
+ mask = 0
117
+ min_prog = 2.0 # sentinel > 1.0
118
+ for (dc, dr), bit in _DOT_MAP.items():
119
+ px = col * 2 + dc
120
+ py = row * 4 + dr
121
+ if (px, py) in self.pixel_progress:
122
+ mask |= bit
123
+ p = self.pixel_progress[(px, py)]
124
+ if p < min_prog:
125
+ min_prog = p
126
+
127
+ if mask == 0:
128
+ text.append(" ")
129
+ else:
130
+ ch = chr(0x2800 + mask)
131
+ style = color if min_prog <= progress else dim_color
132
+ text.append(ch, style=style)
133
+
134
+ if row < self.rows - 1:
135
+ text.append("\n")
136
+
137
+ return text
@@ -0,0 +1,152 @@
1
+ """Transport bar — step scrubber and event navigation controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.message import Message
8
+ from textual.reactive import reactive
9
+ from textual.widgets import Button, ProgressBar, Static
10
+
11
+
12
+ class TransportBar(Vertical):
13
+ """Bottom transport bar — single row with step controls and event nav."""
14
+
15
+ current_step: reactive[int] = reactive(0)
16
+ max_step: reactive[int] = reactive(0)
17
+
18
+ class StepChanged(Message):
19
+ def __init__(self, step: int) -> None:
20
+ self.step = step
21
+ super().__init__()
22
+
23
+ class EventSelected(Message):
24
+ def __init__(self, event_idx: int) -> None:
25
+ self.event_idx = event_idx
26
+ super().__init__()
27
+
28
+ class EpisodeNav(Message):
29
+ def __init__(self, direction: int) -> None:
30
+ self.direction = direction
31
+ super().__init__()
32
+
33
+ class CycleRequested(Message):
34
+ def __init__(self, target: str) -> None:
35
+ self.target = target
36
+ super().__init__()
37
+
38
+ DEFAULT_CSS = """
39
+ TransportBar {
40
+ height: auto;
41
+ background: $boost;
42
+ dock: bottom;
43
+ border-top: hkey $panel;
44
+ }
45
+ TransportBar Horizontal {
46
+ height: 3;
47
+ padding: 0 1;
48
+ align: left middle;
49
+ }
50
+ TransportBar .tb-label {
51
+ width: auto;
52
+ padding: 0 1;
53
+ }
54
+ TransportBar #tb-episode-label {
55
+ width: auto;
56
+ text-style: bold;
57
+ padding: 0 1;
58
+ color: $accent;
59
+ }
60
+ TransportBar .tb-step {
61
+ width: auto;
62
+ text-style: bold;
63
+ padding: 0 1;
64
+ }
65
+ TransportBar ProgressBar {
66
+ width: 1fr;
67
+ padding: 0 1;
68
+ }
69
+ TransportBar Button {
70
+ min-width: 3;
71
+ height: 1;
72
+ margin: 0 0;
73
+ }
74
+ """
75
+
76
+ def compose(self) -> ComposeResult:
77
+ with Horizontal(id="tb-row-1"):
78
+ yield Button("<Ep", id="tb-prev-episode")
79
+ yield Static("", id="tb-episode-label", classes="tb-label")
80
+ yield Button("Ep>", id="tb-next-episode")
81
+ yield Button("<<", id="tb-step-back-10")
82
+ yield Button("<", id="tb-prev-step")
83
+ yield Static("t=0", id="tb-step-display", classes="tb-step")
84
+ yield Button(">", id="tb-next-step")
85
+ yield Button(">>", id="tb-step-fwd-10")
86
+ yield ProgressBar(total=100, show_eta=False, show_percentage=False, id="tb-progress")
87
+ yield Static("", id="tb-range", classes="tb-label")
88
+ yield Button("[", id="tb-prev-event")
89
+ yield Button("]", id="tb-next-event")
90
+
91
+ def watch_current_step(self, step: int) -> None:
92
+ try:
93
+ display = self.query_one("#tb-step-display", Static)
94
+ except Exception:
95
+ return
96
+ display.update(f"Step {step}")
97
+ if self.max_step > 0:
98
+ progress = self.query_one("#tb-progress", ProgressBar)
99
+ progress.update(total=self.max_step, progress=step)
100
+
101
+ def watch_max_step(self, value: int) -> None:
102
+ try:
103
+ label = self.query_one("#tb-range", Static)
104
+ except Exception:
105
+ return
106
+ label.update(f"/{value}")
107
+
108
+ def update_episode_label(self, episode_num: int, episode_total: int) -> None:
109
+ """Update the persistent episode label (D-04)."""
110
+ label = self.query_one("#tb-episode-label", Static)
111
+ label.update(f"Ep {episode_num}/{episode_total}")
112
+
113
+ def update_cycle_label(self, target: str, text: str) -> None:
114
+ """No-op — cycling buttons moved to ActionBar."""
115
+ pass
116
+
117
+ def on_button_pressed(self, event: Button.Pressed) -> None:
118
+ bid = event.button.id
119
+ if bid == "tb-prev-step":
120
+ self.step_backward()
121
+ elif bid == "tb-next-step":
122
+ self.step_forward()
123
+ elif bid == "tb-step-back-10":
124
+ self.step_backward(10)
125
+ elif bid == "tb-step-fwd-10":
126
+ self.step_forward(10)
127
+ elif bid == "tb-prev-event":
128
+ self.post_message(self.EventSelected(-1))
129
+ elif bid == "tb-next-event":
130
+ self.post_message(self.EventSelected(1))
131
+ elif bid == "tb-prev-episode":
132
+ self.post_message(self.EpisodeNav(-1))
133
+ elif bid == "tb-next-episode":
134
+ self.post_message(self.EpisodeNav(1))
135
+
136
+ def step_forward(self, amount: int = 1) -> None:
137
+ new = min(self.current_step + amount, self.max_step)
138
+ if new != self.current_step:
139
+ self.current_step = new
140
+ self.post_message(self.StepChanged(new))
141
+
142
+ def step_backward(self, amount: int = 1) -> None:
143
+ new = max(self.current_step - amount, 0)
144
+ if new != self.current_step:
145
+ self.current_step = new
146
+ self.post_message(self.StepChanged(new))
147
+
148
+ def goto_step(self, step: int) -> None:
149
+ step = max(0, min(step, self.max_step))
150
+ if step != self.current_step:
151
+ self.current_step = step
152
+ self.post_message(self.StepChanged(step))