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,73 @@
1
+ """Causal sequence — event chain timeline and factor attribution visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ from textual.widgets import Static
8
+
9
+ from psystack.models.event import Event
10
+ from psystack.models.isolation import EffectEstimate
11
+
12
+ _TYPE_SHORT = {
13
+ "first_signal_divergence": "sig div",
14
+ "first_action_divergence": "act div",
15
+ "first_risk_spike": "risk",
16
+ "first_boundary_collapse": "boundary",
17
+ "terminal": "terminal",
18
+ "max_metric_gap": "max gap",
19
+ }
20
+
21
+ _FACTOR_STYLES = {
22
+ "world_model": "bold magenta",
23
+ "planner": "bold cyan",
24
+ "env": "bold green",
25
+ "interaction": "bold yellow",
26
+ "unknown": "dim",
27
+ }
28
+
29
+
30
+ class CausalSequence(Static):
31
+ """Displays event chain as a causal arrow sequence, plus factor attribution chain."""
32
+
33
+ DEFAULT_CSS = """
34
+ CausalSequence {
35
+ height: auto;
36
+ padding: 1;
37
+ }
38
+ """
39
+
40
+ def set_events(self, events: list[Event]) -> None:
41
+ """Set the event chain (temporal causal sequence)."""
42
+ if not events:
43
+ self.update("No causal sequence available")
44
+ return
45
+ text = Text()
46
+ text.append("Event chain: ", style="bold")
47
+ for i, evt in enumerate(events):
48
+ if i > 0:
49
+ text.append(" -> ", style="dim")
50
+ label = _TYPE_SHORT.get(evt.type, evt.type)
51
+ text.append(f"[{evt.step}]", style="bold")
52
+ text.append(label)
53
+ self.update(text)
54
+
55
+ def set_factor_chain(self, main_effects: list[EffectEstimate]) -> None:
56
+ """Set the factor attribution chain from isolation results."""
57
+ if not main_effects:
58
+ return
59
+
60
+ # Sort by effect magnitude descending
61
+ sorted_effects = sorted(main_effects, key=lambda e: abs(e.effect), reverse=True)
62
+
63
+ text = Text()
64
+ text.append("\nFactor attribution: ", style="bold")
65
+ for i, eff in enumerate(sorted_effects):
66
+ if i > 0:
67
+ text.append(" > ", style="dim")
68
+ style = _FACTOR_STYLES.get(eff.factor, "")
69
+ conf_pct = f"{eff.confidence:.0%}" if eff.confidence else "?"
70
+ text.append(f"{eff.factor}", style=style)
71
+ text.append(f"({conf_pct})", style="dim")
72
+
73
+ self.update(text)
@@ -0,0 +1,48 @@
1
+ """Comparability summary widget — shows shared config + checkpoint comparison."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from textual.widgets import Static
8
+
9
+
10
+ class ComparabilitySummary(Static):
11
+ """Displays shared config and checkpoint diff for the always-compare builder."""
12
+
13
+ DEFAULT_CSS = """
14
+ ComparabilitySummary {
15
+ height: auto;
16
+ padding: 1 2;
17
+ color: $text-muted;
18
+ }
19
+ """
20
+
21
+ def update_comparison(
22
+ self,
23
+ track: str,
24
+ episodes: int,
25
+ detail_a: str,
26
+ detail_b: str,
27
+ env_overrides: dict[str, Any] | None = None,
28
+ planner_diff: str | None = None,
29
+ ) -> None:
30
+ lines = []
31
+ lines.append(f"Shared: TRACK={track or '(none)'} | EPISODES={episodes}")
32
+
33
+ if env_overrides:
34
+ parts = [f"{k}={v}" for k, v in env_overrides.items()]
35
+ lines.append(f"Env: {', '.join(parts)}")
36
+
37
+ if detail_a != detail_b:
38
+ lines.append(f"Checkpoint: baseline={detail_a} vs candidate={detail_b}")
39
+ else:
40
+ lines.append("Checkpoint: (identical -- not a valid comparison)")
41
+
42
+ if planner_diff:
43
+ lines.append(f"Planner: {planner_diff}")
44
+
45
+ track_ok = bool(track)
46
+ lines.append(f"Checks: track {'OK' if track_ok else 'WARN: no track selected'}")
47
+
48
+ self.update("\n".join(lines))
@@ -0,0 +1,69 @@
1
+ """Context rail — left panel showing run cards and case summary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.widgets import Static
8
+
9
+ from psystack.models.case import Case
10
+
11
+
12
+ class ContextRail(Vertical):
13
+ """Left rail showing run configuration and case context."""
14
+
15
+ DEFAULT_CSS = """
16
+ ContextRail {
17
+ width: 22;
18
+ border: solid $panel;
19
+ padding: 1;
20
+ }
21
+ ContextRail .cr-title {
22
+ text-style: bold;
23
+ padding-bottom: 1;
24
+ }
25
+ ContextRail .cr-section {
26
+ text-style: bold;
27
+ padding: 1 0 0 0;
28
+ color: $accent;
29
+ }
30
+ ContextRail .cr-field {
31
+ padding: 0;
32
+ color: $text-muted;
33
+ }
34
+ """
35
+
36
+ def compose(self) -> ComposeResult:
37
+ yield Static("Case Context", classes="cr-title")
38
+ yield Static("No case loaded", id="cr-content")
39
+
40
+ def set_case(self, case: Case) -> None:
41
+ content = self.query_one("#cr-content", Static)
42
+ lines = []
43
+
44
+ for label, run in [("[Run A]", case.run_a), ("[Run B]", case.run_b)]:
45
+ if run is None:
46
+ continue
47
+ lines.append(label)
48
+ lines.append(f" wm: {_short(run.world_model_ref)}")
49
+ lines.append(f" pln: {run.planner_ref or 'cem'}")
50
+ lines.append(f" ep: {run.num_episodes}")
51
+ lines.append("")
52
+
53
+ lines.append("[Shared]")
54
+ lines.append(f" track: {case.track_ref or '(none)'}")
55
+ lines.append(f" episodes: {case.episode_count}")
56
+ seeds_str = ", ".join(str(s) for s in case.eval_seeds) if case.eval_seeds else "auto"
57
+ lines.append(f" seeds: {seeds_str}")
58
+ lines.append(f" align: {case.alignment_method}")
59
+
60
+ content.update("\n".join(lines))
61
+
62
+
63
+ def _short(ref: str) -> str:
64
+ """Shorten a reference path for display."""
65
+ if not ref:
66
+ return "(none)"
67
+ # Show just filename
68
+ parts = ref.replace("\\", "/").split("/")
69
+ return parts[-1] if parts else ref
@@ -0,0 +1,32 @@
1
+ """Effect estimate table for attribution view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import DataTable
6
+
7
+ from psystack.models.isolation import EffectEstimate
8
+
9
+
10
+ class EffectTable(DataTable):
11
+ DEFAULT_CSS = """
12
+ EffectTable {
13
+ height: auto;
14
+ max-height: 10;
15
+ }
16
+ """
17
+
18
+ def on_mount(self) -> None:
19
+ self.cursor_type = "row"
20
+ self.add_columns("Factor", "Effect", "Confidence", "Support tests")
21
+
22
+ def load_effects(self, effects: list[EffectEstimate]) -> None:
23
+ self.clear()
24
+ for e in effects:
25
+ conf_str = f"{e.confidence:.2f}" if e.confidence is not None else "—"
26
+ support_str = ", ".join(e.support_tests) if e.support_tests else "—"
27
+ self.add_row(
28
+ e.factor,
29
+ f"{e.effect:+.4f}",
30
+ conf_str,
31
+ support_str,
32
+ )
@@ -0,0 +1,176 @@
1
+ """Event navigator — scrollable event list with severity-grouped sections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.app import ComposeResult
7
+ from textual.containers import VerticalScroll
8
+ from textual.message import Message
9
+ from textual.widgets import Static
10
+
11
+ from psystack.models.event import Event
12
+
13
+ _SEVERITY_STYLES = {
14
+ "critical": ("\u25cf", "bold red"),
15
+ "warning": ("\u25b2", "bold yellow"),
16
+ "info": ("\u25cb", "dim"),
17
+ }
18
+
19
+ _TYPE_SHORT = {
20
+ "first_signal_divergence": "sig_div",
21
+ "first_action_divergence": "act_div",
22
+ "first_risk_spike": "risk",
23
+ "first_boundary_collapse": "boundary",
24
+ "terminal": "terminal",
25
+ "max_metric_gap": "max_gap",
26
+ "first_divergence": "first_div",
27
+ "divergence_window": "div_window",
28
+ "risk_spike": "risk",
29
+ "off_track_terminal": "off_track",
30
+ "max_gap": "max_gap",
31
+ }
32
+
33
+
34
+ class EventNavigator(VerticalScroll):
35
+ """Right-rail event list — grouped by investigation relevance."""
36
+
37
+ _highlighted_idx: int = -1
38
+
39
+ class EventClicked(Message):
40
+ def __init__(self, event_idx: int, step: int) -> None:
41
+ self.event_idx = event_idx
42
+ self.step = step
43
+ super().__init__()
44
+
45
+ DEFAULT_CSS = """
46
+ EventNavigator {
47
+ width: 100%;
48
+ height: auto;
49
+ max-height: 50%;
50
+ min-height: 6;
51
+ border: round $panel;
52
+ padding: 0;
53
+ }
54
+ EventNavigator .en-item {
55
+ padding: 0 1;
56
+ height: 1;
57
+ }
58
+ EventNavigator .en-item-selected {
59
+ padding: 0 1;
60
+ height: 1;
61
+ background: $accent 15%;
62
+ border-left: wide $accent;
63
+ }
64
+ EventNavigator .en-section {
65
+ padding: 0 1;
66
+ height: 1;
67
+ color: $text-muted;
68
+ text-style: bold;
69
+ }
70
+ EventNavigator .en-empty {
71
+ padding: 1 2;
72
+ color: $text-muted;
73
+ }
74
+ """
75
+
76
+ def __init__(self, **kwargs) -> None:
77
+ super().__init__(**kwargs)
78
+ self._events: list[Event] = []
79
+ # Direct widget references — index maps to Static widget, avoids ID collisions
80
+ self._item_widgets: list[Static] = []
81
+
82
+ def compose(self) -> ComposeResult:
83
+ self.border_title = "Divergences"
84
+ yield from ()
85
+
86
+ def set_events(self, events: list[Event]) -> None:
87
+ self._events = events
88
+ self._highlighted_idx = -1
89
+ self._item_widgets = []
90
+
91
+ # Remove all existing children synchronously via remove_children
92
+ self.remove_children()
93
+
94
+ if not events:
95
+ self.mount(Static("No divergences detected", classes="en-empty"))
96
+ self.border_title = "Divergences"
97
+ return
98
+
99
+ suggested = [(i, e) for i, e in enumerate(events) if e.severity in ("critical", "warning")]
100
+ other = [(i, e) for i, e in enumerate(events) if e.severity == "info"]
101
+
102
+ to_mount: list[Static] = []
103
+
104
+ if suggested:
105
+ to_mount.append(Static("Suggested Path", classes="en-section"))
106
+ for i, evt in suggested:
107
+ w = self._build_event_widget(i, evt, selected=False)
108
+ self._item_widgets.append((i, w))
109
+ to_mount.append(w)
110
+
111
+ if other:
112
+ to_mount.append(Static("Other Divergences", classes="en-section"))
113
+ for i, evt in other:
114
+ w = self._build_event_widget(i, evt, selected=False)
115
+ self._item_widgets.append((i, w))
116
+ to_mount.append(w)
117
+
118
+ if not suggested and not other:
119
+ for i, evt in enumerate(events):
120
+ w = self._build_event_widget(i, evt, selected=False)
121
+ self._item_widgets.append((i, w))
122
+ to_mount.append(w)
123
+
124
+ self.mount(*to_mount)
125
+ self.border_title = f"Divergences ({len(events)})"
126
+
127
+ def _build_event_widget(self, idx: int, evt: Event, selected: bool) -> Static:
128
+ line = self._build_line(idx, evt, selected)
129
+ cls = "en-item-selected" if selected else "en-item"
130
+ # No ID — we use _item_widgets list for direct reference
131
+ return Static(line, classes=cls)
132
+
133
+ def _build_line(self, idx: int, evt: Event, selected: bool) -> Text:
134
+ icon, icon_style = _SEVERITY_STYLES.get(evt.severity, ("?", ""))
135
+ label = _TYPE_SHORT.get(evt.type, evt.type.replace("_", " "))
136
+ line = Text()
137
+ line.append("> " if selected else " ", style="bold" if selected else "")
138
+ line.append(icon, style=icon_style)
139
+ line.append(f" t={evt.step} ", style="bold")
140
+ line.append(label, style="")
141
+ return line
142
+
143
+ def highlight(self, idx: int | None) -> None:
144
+ """Highlight the event at idx."""
145
+ old = self._highlighted_idx
146
+ new = idx if idx is not None else -1
147
+ if old == new:
148
+ return
149
+ self._highlighted_idx = new
150
+
151
+ # Deselect old
152
+ for item_idx, widget in self._item_widgets:
153
+ if item_idx == old:
154
+ try:
155
+ widget.set_classes("en-item")
156
+ widget.update(self._build_line(item_idx, self._events[item_idx], selected=False))
157
+ except Exception:
158
+ pass
159
+ break
160
+
161
+ # Select new
162
+ for item_idx, widget in self._item_widgets:
163
+ if item_idx == new:
164
+ try:
165
+ widget.set_classes("en-item-selected")
166
+ widget.update(self._build_line(item_idx, self._events[item_idx], selected=True))
167
+ widget.scroll_visible()
168
+ except Exception:
169
+ pass
170
+ break
171
+
172
+ def on_click(self, event) -> None:
173
+ for item_idx, widget in self._item_widgets:
174
+ if widget is event.widget:
175
+ self.post_message(self.EventClicked(item_idx, self._events[item_idx].step))
176
+ return
@@ -0,0 +1,67 @@
1
+ """Explanation card — confidence bar, tier badge, and support basis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.widgets import Static
8
+
9
+ from psystack.models.explanation import Explanation
10
+
11
+ _TIER_LABELS = {
12
+ "tier_0": "Tier 0: Correlational",
13
+ "tier_1": "Tier 1: Component-isolated",
14
+ "tier_2": "Tier 2: Counterfactual",
15
+ "tier_3": "Tier 3: Mechanistic",
16
+ }
17
+
18
+
19
+ def _confidence_bar(confidence: float, width: int = 20) -> str:
20
+ """Render a text-mode confidence bar."""
21
+ filled = int(confidence * width)
22
+ return "#" * filled + "-" * (width - filled)
23
+
24
+
25
+ class ExplanationCard(Vertical):
26
+ """Displays an explanation with confidence visualization."""
27
+
28
+ DEFAULT_CSS = """
29
+ ExplanationCard {
30
+ height: auto;
31
+ border: solid $panel;
32
+ padding: 1;
33
+ margin: 0 0 1 0;
34
+ }
35
+ ExplanationCard .ec-label {
36
+ text-style: bold;
37
+ }
38
+ ExplanationCard .ec-tier {
39
+ color: $accent;
40
+ }
41
+ ExplanationCard .ec-confidence {
42
+ padding: 0;
43
+ }
44
+ ExplanationCard .ec-details {
45
+ color: $text-muted;
46
+ }
47
+ """
48
+
49
+ def __init__(self, explanation: Explanation, rank: int = 1, **kwargs) -> None:
50
+ super().__init__(**kwargs)
51
+ self._explanation = explanation
52
+ self._rank = rank
53
+
54
+ def compose(self) -> ComposeResult:
55
+ ex = self._explanation
56
+ yield Static(f"{self._rank}. {ex.label}", classes="ec-label")
57
+ yield Static(_TIER_LABELS.get(ex.tier, ex.tier), classes="ec-tier")
58
+ yield Static(
59
+ f" confidence: {ex.confidence:.2f} [{_confidence_bar(ex.confidence)}]",
60
+ classes="ec-confidence",
61
+ )
62
+ if ex.support_basis:
63
+ yield Static(f" basis: {' | '.join(ex.support_basis)}", classes="ec-details")
64
+ if ex.competing:
65
+ yield Static(f" competing: {', '.join(ex.competing)}", classes="ec-details")
66
+ if ex.falsifiers:
67
+ yield Static(f" falsifiers: {'; '.join(ex.falsifiers)}", classes="ec-details")
@@ -0,0 +1,73 @@
1
+ """Falsifier list — conditions that would disprove the explanation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Vertical
9
+ from textual.widgets import Static
10
+
11
+ from psystack.models.isolation import EffectEstimate
12
+
13
+
14
+ class FalsifierList(Vertical):
15
+ """Displays falsification conditions for the current explanation."""
16
+
17
+ DEFAULT_CSS = """
18
+ FalsifierList {
19
+ height: auto;
20
+ border: solid $panel;
21
+ padding: 1;
22
+ }
23
+ FalsifierList .fl-title {
24
+ text-style: bold;
25
+ padding-bottom: 1;
26
+ }
27
+ FalsifierList .fl-item {
28
+ color: $text-muted;
29
+ padding: 0 0 0 2;
30
+ }
31
+ """
32
+
33
+ def compose(self) -> ComposeResult:
34
+ yield Static("Falsifiers", classes="fl-title")
35
+ yield Static("No falsifiers defined", id="fl-content", classes="fl-item")
36
+
37
+ def set_falsifiers(self, falsifiers: list[str]) -> None:
38
+ """Set falsifier conditions from explanation model."""
39
+ content = self.query_one("#fl-content", Static)
40
+ if not falsifiers:
41
+ content.update("No falsifiers defined")
42
+ return
43
+ lines = [f"? {f}" for f in falsifiers]
44
+ content.update("\n".join(lines))
45
+
46
+ def set_interaction_effects(self, interactions: list[EffectEstimate]) -> None:
47
+ """Set falsifiers derived from interaction effects (contradictions)."""
48
+ content = self.query_one("#fl-content", Static)
49
+ if not interactions:
50
+ return
51
+
52
+ text = Text()
53
+ text.append("Interaction effects (potential contradictions):\n", style="bold")
54
+ for eff in interactions:
55
+ # An interaction effect suggests the main-effect attribution
56
+ # may be incomplete — the factors interact non-additively
57
+ severity = "HIGH" if abs(eff.effect) > 0.1 else "low"
58
+ style = "bold red" if severity == "HIGH" else "dim"
59
+ text.append(f" [{severity}] ", style=style)
60
+ text.append(f"{eff.factor}: effect={eff.effect:.4f}")
61
+ if eff.confidence:
62
+ text.append(f" (conf={eff.confidence:.0%})")
63
+ text.append("\n")
64
+
65
+ # If main factors explain <80% of total, add a falsifier note
66
+ text.append("\nFalsification conditions:\n", style="bold")
67
+ for eff in interactions:
68
+ text.append(
69
+ f" ? If {eff.factor} effect persists after controlling main factors, "
70
+ f"attribution is incomplete\n"
71
+ )
72
+
73
+ content.update(text)
@@ -0,0 +1,22 @@
1
+ """Compact single-line strip showing top signal deltas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import Static
6
+
7
+
8
+ class FocusSignalsStrip(Static):
9
+ """Single-line display of top signal deltas at the current step/event."""
10
+
11
+ DEFAULT_CSS = """
12
+ FocusSignalsStrip {
13
+ height: 1;
14
+ background: $boost;
15
+ padding: 0 1;
16
+ }
17
+ """
18
+
19
+ def set_signals(self, deltas: list[tuple[str, float]]) -> None:
20
+ """Show top signal deltas as compact line."""
21
+ parts = [f"{name} \u0394{val:+.3f}" for name, val in deltas[:5]]
22
+ self.update(" \u2502 ".join(parts) if parts else "")
@@ -0,0 +1,85 @@
1
+ """HelpOverlay — toggleable command list popup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.widgets import Static
8
+
9
+
10
+ # (key, description) pairs — grouped
11
+ _COMMANDS: list[tuple[str, str]] = [
12
+ ("r", "Run evaluation"),
13
+ ("A", "Reanalyze"),
14
+ ("E", "Edit case"),
15
+ ("x", "Export report"),
16
+ ("e", "Evidence drawer"),
17
+ ("c", "Context drawer"),
18
+ ("a", "Attribution"),
19
+ ("b", "Builder"),
20
+ ("", ""),
21
+ ("j / ]", "Next event"),
22
+ ("k / [", "Prev event"),
23
+ (", / . / l", "Step back / forward"),
24
+ ("H / L", "Step back / forward 10"),
25
+ ("", ""),
26
+ ("n / N", "Next / prev episode"),
27
+ ("m", "Cycle signal"),
28
+ ("t", "Cycle timeline mode"),
29
+ ("w", "Cycle event window"),
30
+ ("v", "Cycle live view"),
31
+ ("", ""),
32
+ ("esc", "Back"),
33
+ ("h", "Close help"),
34
+ ]
35
+
36
+
37
+ class HelpOverlay(Vertical):
38
+ """Floating command reference panel."""
39
+
40
+ DEFAULT_CSS = """
41
+ HelpOverlay {
42
+ display: none;
43
+ dock: bottom;
44
+ height: auto;
45
+ max-height: 70%;
46
+ background: $surface;
47
+ border-top: tall $accent;
48
+ padding: 1 2;
49
+ }
50
+ HelpOverlay.visible {
51
+ display: block;
52
+ }
53
+ HelpOverlay .help-title {
54
+ text-style: bold;
55
+ color: $accent;
56
+ padding: 0 0 1 0;
57
+ }
58
+ HelpOverlay .help-row {
59
+ height: 1;
60
+ padding: 0 1;
61
+ }
62
+ HelpOverlay .help-key {
63
+ width: 12;
64
+ text-style: bold;
65
+ color: $text;
66
+ }
67
+ HelpOverlay .help-sep {
68
+ height: 1;
69
+ }
70
+ """
71
+
72
+ def compose(self) -> ComposeResult:
73
+ yield Static("Commands", classes="help-title")
74
+ for key, desc in _COMMANDS:
75
+ if not key and not desc:
76
+ yield Static("", classes="help-sep")
77
+ else:
78
+ yield Static(f" {key:<10} {desc}", classes="help-row")
79
+
80
+ def toggle(self) -> None:
81
+ self.toggle_class("visible")
82
+
83
+ @property
84
+ def is_visible(self) -> bool:
85
+ return self.has_class("visible")