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,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")
|