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.
- psystack/__init__.py +3 -0
- psystack/__main__.py +5 -0
- psystack/adapters/__init__.py +0 -0
- psystack/adapters/f1/__init__.py +0 -0
- psystack/adapters/f1/controllers.py +56 -0
- psystack/adapters/f1/degrade.py +31 -0
- psystack/adapters/f1/env.py +48 -0
- psystack/adapters/f1/factory.py +182 -0
- psystack/adapters/f1/live_viewer.py +143 -0
- psystack/adapters/f1/planner.py +39 -0
- psystack/adapters/f1/signals.py +353 -0
- psystack/adapters/f1/world_model.py +75 -0
- psystack/adapters/registry.py +35 -0
- psystack/cli/__init__.py +0 -0
- psystack/cli/app.py +21 -0
- psystack/cli/version_check.py +32 -0
- psystack/cli/wizard/__init__.py +3 -0
- psystack/cli/wizard/discovery.py +65 -0
- psystack/cli/wizard/models.py +38 -0
- psystack/cli/wizard/questions.py +174 -0
- psystack/cli/wizard/review.py +54 -0
- psystack/cli/wizard/service.py +181 -0
- psystack/core/__init__.py +0 -0
- psystack/core/config.py +77 -0
- psystack/core/contracts.py +124 -0
- psystack/core/signal_schema.py +54 -0
- psystack/evaluation/__init__.py +0 -0
- psystack/evaluation/metrics/__init__.py +22 -0
- psystack/evaluation/metrics/offtrack.py +30 -0
- psystack/evaluation/metrics/prediction_error.py +71 -0
- psystack/evaluation/metrics/progress.py +22 -0
- psystack/evaluation/metrics/reward.py +22 -0
- psystack/evaluation/metrics/survival.py +22 -0
- psystack/models/__init__.py +42 -0
- psystack/models/case.py +30 -0
- psystack/models/comparison.py +30 -0
- psystack/models/episode.py +82 -0
- psystack/models/evaluation_result.py +51 -0
- psystack/models/event.py +40 -0
- psystack/models/evidence.py +18 -0
- psystack/models/explanation.py +23 -0
- psystack/models/isolation.py +35 -0
- psystack/models/manifest.py +24 -0
- psystack/models/metric.py +14 -0
- psystack/models/project.py +25 -0
- psystack/models/run.py +50 -0
- psystack/models/signal.py +14 -0
- psystack/models/swap.py +25 -0
- psystack/pipeline/__init__.py +0 -0
- psystack/pipeline/case_io.py +22 -0
- psystack/pipeline/compare/__init__.py +4 -0
- psystack/pipeline/compare/decision.py +20 -0
- psystack/pipeline/compare/execution.py +50 -0
- psystack/pipeline/compare/service.py +95 -0
- psystack/pipeline/compare/stats.py +60 -0
- psystack/pipeline/compare_module.py +259 -0
- psystack/pipeline/context.py +194 -0
- psystack/pipeline/episodes.py +109 -0
- psystack/pipeline/event_extraction.py +253 -0
- psystack/pipeline/events/__init__.py +6 -0
- psystack/pipeline/events/config.py +41 -0
- psystack/pipeline/events/detection.py +231 -0
- psystack/pipeline/events/divergence.py +106 -0
- psystack/pipeline/isolation/__init__.py +4 -0
- psystack/pipeline/isolation/attribution.py +187 -0
- psystack/pipeline/isolation/designs.py +35 -0
- psystack/pipeline/isolation/executor.py +60 -0
- psystack/pipeline/isolation/planner.py +10 -0
- psystack/pipeline/live_update.py +59 -0
- psystack/pipeline/metrics_util.py +65 -0
- psystack/pipeline/paired_runner.py +185 -0
- psystack/pipeline/runner.py +107 -0
- psystack/pipeline/stages/__init__.py +22 -0
- psystack/pipeline/stages/attribute.py +78 -0
- psystack/pipeline/stages/base.py +18 -0
- psystack/pipeline/stages/compare.py +37 -0
- psystack/pipeline/stages/events.py +53 -0
- psystack/pipeline/stages/isolate.py +88 -0
- psystack/pipeline/stages/report.py +59 -0
- psystack/pipeline/staleness.py +33 -0
- psystack/pipeline/state.py +31 -0
- psystack/pipeline/workspace.py +177 -0
- psystack/reporting/__init__.py +0 -0
- psystack/reporting/bundle.py +74 -0
- psystack/reporting/evidence.py +28 -0
- psystack/reporting/renderers/__init__.py +0 -0
- psystack/reporting/renderers/console.py +27 -0
- psystack/reporting/renderers/html.py +28 -0
- psystack/reporting/renderers/json.py +13 -0
- psystack/reporting/templates/investigation_report.html.j2 +85 -0
- psystack/reporting/templates/report.html.j2 +99 -0
- psystack/reporting/types.py +33 -0
- psystack/tui/__init__.py +0 -0
- psystack/tui/actions.py +78 -0
- psystack/tui/app.py +1188 -0
- psystack/tui/detection.py +241 -0
- psystack/tui/screens/__init__.py +1 -0
- psystack/tui/screens/attribution.py +252 -0
- psystack/tui/screens/case_history.py +131 -0
- psystack/tui/screens/case_verdict.py +657 -0
- psystack/tui/screens/command_palette.py +70 -0
- psystack/tui/screens/drawers/__init__.py +1 -0
- psystack/tui/screens/drawers/context_drawer.py +90 -0
- psystack/tui/screens/drawers/evidence_drawer.py +113 -0
- psystack/tui/screens/error_modal.py +54 -0
- psystack/tui/screens/investigation.py +686 -0
- psystack/tui/screens/run_builder.py +492 -0
- psystack/tui/screens/workspace_picker.py +69 -0
- psystack/tui/services.py +769 -0
- psystack/tui/state.py +137 -0
- psystack/tui/styles/app.tcss +224 -0
- psystack/tui/views/__init__.py +0 -0
- psystack/tui/widgets/__init__.py +0 -0
- psystack/tui/widgets/action_bar.py +42 -0
- psystack/tui/widgets/artifact_list.py +38 -0
- psystack/tui/widgets/artifact_preview.py +34 -0
- psystack/tui/widgets/attribution_decision_card.py +55 -0
- psystack/tui/widgets/case_bar.py +108 -0
- psystack/tui/widgets/causal_sequence.py +73 -0
- psystack/tui/widgets/comparability_summary.py +48 -0
- psystack/tui/widgets/context_rail.py +69 -0
- psystack/tui/widgets/effect_table.py +32 -0
- psystack/tui/widgets/event_navigator.py +176 -0
- psystack/tui/widgets/explanation_card.py +67 -0
- psystack/tui/widgets/falsifier_list.py +73 -0
- psystack/tui/widgets/focus_signals_strip.py +22 -0
- psystack/tui/widgets/help_overlay.py +85 -0
- psystack/tui/widgets/isolation_case_detail.py +67 -0
- psystack/tui/widgets/isolation_case_table.py +50 -0
- psystack/tui/widgets/live_run_monitor.py +337 -0
- psystack/tui/widgets/metric_detail.py +93 -0
- psystack/tui/widgets/metric_table.py +71 -0
- psystack/tui/widgets/progress_summary.py +300 -0
- psystack/tui/widgets/run_config_panel.py +163 -0
- psystack/tui/widgets/run_monitor.py +91 -0
- psystack/tui/widgets/section_title.py +15 -0
- psystack/tui/widgets/signal_timeline.py +206 -0
- psystack/tui/widgets/status_badge.py +52 -0
- psystack/tui/widgets/step_inspector.py +105 -0
- psystack/tui/widgets/tier_indicator.py +44 -0
- psystack/tui/widgets/track_map.py +137 -0
- psystack/tui/widgets/transport_bar.py +152 -0
- psystack/tui/widgets/verdict_strip.py +103 -0
- psystack-0.1.0.dist-info/METADATA +42 -0
- psystack-0.1.0.dist-info/RECORD +149 -0
- psystack-0.1.0.dist-info/WHEEL +5 -0
- psystack-0.1.0.dist-info/entry_points.txt +5 -0
- psystack-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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))
|