simjecture 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 (75) hide show
  1. conjecture_solver/__init__.py +10 -0
  2. conjecture_solver/__main__.py +4 -0
  3. conjecture_solver/action_handlers.py +459 -0
  4. conjecture_solver/adapters/__init__.py +7 -0
  5. conjecture_solver/adapters/base.py +96 -0
  6. conjecture_solver/adapters/fake.py +155 -0
  7. conjecture_solver/adapters/pic.py +196 -0
  8. conjecture_solver/adapters/warpx.py +1375 -0
  9. conjecture_solver/autonomous_research.py +1071 -0
  10. conjecture_solver/benchmarks/__init__.py +6 -0
  11. conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
  12. conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
  13. conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
  14. conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
  15. conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
  16. conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
  17. conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
  18. conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
  19. conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
  20. conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
  21. conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
  22. conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
  23. conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
  24. conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
  25. conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
  26. conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
  27. conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
  28. conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
  29. conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
  30. conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
  31. conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
  32. conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
  33. conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
  34. conjecture_solver/campaign.py +406 -0
  35. conjecture_solver/cli.py +963 -0
  36. conjecture_solver/confirmation.py +287 -0
  37. conjecture_solver/control.py +234 -0
  38. conjecture_solver/deployment.py +874 -0
  39. conjecture_solver/discovery.py +68 -0
  40. conjecture_solver/domains/__init__.py +40 -0
  41. conjecture_solver/domains/base.py +157 -0
  42. conjecture_solver/domains/kinetic_sufficiency.py +521 -0
  43. conjecture_solver/ledger.py +238 -0
  44. conjecture_solver/lifecycle.py +131 -0
  45. conjecture_solver/literature.py +494 -0
  46. conjecture_solver/llm.py +228 -0
  47. conjecture_solver/models.py +279 -0
  48. conjecture_solver/mvp_agent.py +3212 -0
  49. conjecture_solver/mvp_claims.py +1279 -0
  50. conjecture_solver/mvp_control.py +322 -0
  51. conjecture_solver/mvp_guidance.py +172 -0
  52. conjecture_solver/mvp_launch.py +1296 -0
  53. conjecture_solver/mvp_monitor.py +1487 -0
  54. conjecture_solver/mvp_skills.py +538 -0
  55. conjecture_solver/orchestration.py +578 -0
  56. conjecture_solver/outbox.py +526 -0
  57. conjecture_solver/parameters.py +394 -0
  58. conjecture_solver/proposals.py +427 -0
  59. conjecture_solver/research_tools.py +278 -0
  60. conjecture_solver/schema_export.py +246 -0
  61. conjecture_solver/search.py +1047 -0
  62. conjecture_solver/semantics.py +146 -0
  63. conjecture_solver/tui/__init__.py +7 -0
  64. conjecture_solver/tui/app.py +202 -0
  65. conjecture_solver/tui/claim_views.py +213 -0
  66. conjecture_solver/tui/screens.py +1014 -0
  67. conjecture_solver/warpx_analysis.py +184 -0
  68. conjecture_solver/warpx_campaign.py +148 -0
  69. conjecture_solver/warpx_confirmation.py +617 -0
  70. simjecture-0.1.0.dist-info/METADATA +400 -0
  71. simjecture-0.1.0.dist-info/RECORD +75 -0
  72. simjecture-0.1.0.dist-info/WHEEL +4 -0
  73. simjecture-0.1.0.dist-info/entry_points.txt +4 -0
  74. simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
  75. simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
@@ -0,0 +1,1014 @@
1
+ """Screens for the optional Simjecture terminal UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from textual import work
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, VerticalScroll
13
+ from textual.screen import ModalScreen, Screen
14
+ from textual.widgets import (
15
+ Button,
16
+ Footer,
17
+ Header,
18
+ Input,
19
+ ListItem,
20
+ ListView,
21
+ TextArea,
22
+ )
23
+ from textual.widgets import (
24
+ Label as TextualLabel,
25
+ )
26
+ from textual.widgets import (
27
+ Static as TextualStatic,
28
+ )
29
+
30
+ from ..mvp_launch import (
31
+ MVPLaunchRequest,
32
+ ResumeError,
33
+ load_supervisor_record,
34
+ materialize_operator_input,
35
+ prepare_resume,
36
+ process_identity_matches,
37
+ request_verified_pause,
38
+ start_managed_campaign,
39
+ validate_campaign_id,
40
+ )
41
+ from ..mvp_monitor import (
42
+ ClaimSummary,
43
+ MVPRunMonitor,
44
+ MVPRunSnapshot,
45
+ RunPhase,
46
+ contained_path,
47
+ discover_recent_runs,
48
+ format_age,
49
+ format_bytes,
50
+ format_duration,
51
+ installed_capability_descriptors,
52
+ list_contained_artifacts,
53
+ )
54
+ from .claim_views import (
55
+ ClaimTreeRow,
56
+ audit_row_label,
57
+ build_hypothesis_tree,
58
+ build_validation_tree,
59
+ hypothesis_row_label,
60
+ scientific_ancestor_id,
61
+ validation_row_label,
62
+ )
63
+
64
+ if TYPE_CHECKING:
65
+ from .app import ConjectureSolverApp
66
+
67
+
68
+ class Static(TextualStatic):
69
+ """Static text that preserves scientific notation instead of parsing markup."""
70
+
71
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
72
+ kwargs.setdefault("markup", False)
73
+ super().__init__(*args, **kwargs)
74
+
75
+
76
+ class Label(TextualLabel):
77
+ """Plain label used for hypotheses, claims, paths, and audit records."""
78
+
79
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
80
+ kwargs.setdefault("markup", False)
81
+ super().__init__(*args, **kwargs)
82
+
83
+
84
+ def _app(screen: Screen[object]) -> ConjectureSolverApp:
85
+ return screen.app # type: ignore[return-value]
86
+
87
+
88
+ def _phase_class(label: str) -> str:
89
+ lowered = label.lower()
90
+ if lowered == "running":
91
+ return "phase-running"
92
+ if lowered == "completed":
93
+ return "phase-completed"
94
+ if "cancel" in lowered:
95
+ return "phase-cancelled"
96
+ if "fail" in lowered:
97
+ return "phase-failed"
98
+ if lowered == "initialized":
99
+ return "phase-initialized"
100
+ if "pause" in lowered:
101
+ return "phase-paused"
102
+ return "phase-incomplete"
103
+
104
+
105
+ def _default_campaign_id(now: datetime | None = None) -> str:
106
+ stamp = (now or datetime.now(UTC)).strftime("%Y%m%d-%H%M%S")
107
+ return f"campaign-{stamp}"
108
+
109
+
110
+ def _claim_detail_body(claim: ClaimSummary) -> str:
111
+ parent = claim.parent_id or "(none)"
112
+ reason = claim.closed_reason or "(open)"
113
+ return "\n".join(
114
+ [
115
+ f"id: {claim.id}",
116
+ f"kind: {claim.kind or 'unknown'}",
117
+ f"relation: {claim.relation or 'unknown'}",
118
+ f"parent: {parent}",
119
+ f"status: {claim.status}",
120
+ f"evidence contracts: {claim.contract_count}",
121
+ f"linked evidence: {claim.evidence_count}",
122
+ f"closed reason: {reason}",
123
+ "",
124
+ claim.statement or "(empty statement)",
125
+ "",
126
+ "Status is read from the durable ledger, not model prose.",
127
+ ]
128
+ )
129
+
130
+
131
+ def _sync_list_labels(
132
+ listing: ListView,
133
+ labels: tuple[str, ...],
134
+ previous: tuple[str, ...] | None,
135
+ ) -> tuple[str, ...]:
136
+ """Update claim rows without unmounting an unchanged list every timer tick."""
137
+
138
+ if labels == previous:
139
+ return labels
140
+
141
+ items = tuple(listing.children)
142
+ shared_count = min(len(items), len(labels))
143
+ for index in range(shared_count):
144
+ if (
145
+ previous is None
146
+ or index >= len(previous)
147
+ or previous[index] != labels[index]
148
+ ):
149
+ items[index].query_one(Label).update(labels[index])
150
+
151
+ if len(items) > len(labels):
152
+ for item in items[len(labels) :]:
153
+ item.remove()
154
+ elif len(items) < len(labels):
155
+ listing.extend(
156
+ ListItem(Label(label)) for label in labels[len(items) :]
157
+ )
158
+ return labels
159
+
160
+
161
+ class HomeScreen(Screen[None]):
162
+ BINDINGS = [
163
+ Binding("n", "new_run", "New run", priority=True),
164
+ Binding("enter", "attach", "Attach"),
165
+ Binding("r", "refresh", "Refresh", priority=True),
166
+ Binding("q", "leave", "Quit", priority=True),
167
+ ]
168
+
169
+ def __init__(self, scan_roots: list[str | Path] | None = None) -> None:
170
+ super().__init__()
171
+ self.scan_roots = scan_roots
172
+ self.runs: list[tuple[str, Path]] = []
173
+
174
+ def compose(self) -> ComposeResult:
175
+ yield Header(show_clock=True)
176
+ yield Static(
177
+ "Attach to a durable run directory or start a new campaign. "
178
+ "Audit artifacts remain the source of truth.",
179
+ id="home-intro",
180
+ )
181
+ yield Label("Recent runs", classes="panel-title")
182
+ yield ListView(id="recent-runs")
183
+ yield Static("", id="home-status")
184
+ with Horizontal(classes="toolbar"):
185
+ yield Button("New run", id="new-run", variant="primary")
186
+ yield Button("Refresh", id="refresh")
187
+ yield Button("Quit", id="quit")
188
+ yield Footer()
189
+
190
+ def on_mount(self) -> None:
191
+ self.action_refresh()
192
+
193
+ def action_refresh(self) -> None:
194
+ listing = self.query_one("#recent-runs", ListView)
195
+ listing.clear()
196
+ self.runs = []
197
+ found = discover_recent_runs(self.scan_roots, limit=20)
198
+ if not found:
199
+ listing.append(ListItem(Label("(no recent run directories found)")))
200
+ self.query_one("#home-status", Static).update(
201
+ "No mvp_manifest.json files were found under the current artifacts tree."
202
+ )
203
+ return
204
+ for item in found:
205
+ path = Path(item.run_directory)
206
+ hypothesis = (item.hypothesis or "").replace("\n", " ")
207
+ if len(hypothesis) > 72:
208
+ hypothesis = hypothesis[:69] + "..."
209
+ record = load_supervisor_record(path)
210
+ live = bool(record and process_identity_matches(record))
211
+ badge = "live" if live else item.phase.value
212
+ label = f"{path.name} {badge} {hypothesis or '(no hypothesis)'}"
213
+ listing.append(ListItem(Label(label)))
214
+ self.runs.append((label, path))
215
+ self.query_one("#home-status", Static).update(
216
+ f"{len(self.runs)} recent run{'s' if len(self.runs) != 1 else ''} found."
217
+ )
218
+
219
+ def action_new_run(self) -> None:
220
+ self.app.push_screen(NewRunScreen())
221
+
222
+ def action_attach(self) -> None:
223
+ listing = self.query_one("#recent-runs", ListView)
224
+ index = listing.index
225
+ if index is None or index >= len(self.runs):
226
+ self.query_one("#home-status", Static).update("Select a run to attach.")
227
+ return
228
+ self.app.push_screen(DashboardScreen(self.runs[index][1]))
229
+
230
+ def action_leave(self) -> None:
231
+ self.app.exit()
232
+
233
+ def on_button_pressed(self, event: Button.Pressed) -> None:
234
+ if event.button.id == "new-run":
235
+ self.action_new_run()
236
+ elif event.button.id == "refresh":
237
+ self.action_refresh()
238
+ elif event.button.id == "quit":
239
+ self.action_leave()
240
+
241
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
242
+ del event
243
+ self.action_attach()
244
+
245
+
246
+ class NewRunScreen(Screen[None]):
247
+ BINDINGS = [
248
+ Binding("escape", "back", "Back"),
249
+ Binding("ctrl+s", "review", "Review"),
250
+ ]
251
+
252
+ def __init__(self) -> None:
253
+ super().__init__()
254
+ self.last_error = ""
255
+ self.campaign_default = _default_campaign_id()
256
+ self.output_follows_campaign = True
257
+
258
+ def compose(self) -> ComposeResult:
259
+ yield Header(show_clock=True)
260
+ with VerticalScroll():
261
+ yield Static("New campaign", classes="panel-title")
262
+ yield Static(
263
+ "The hypothesis is the scientific contract. The instruction is an "
264
+ "optional operational constraint and does not change claim identity.",
265
+ id="form-intro",
266
+ )
267
+ yield Label("Root hypothesis", classes="form-label")
268
+ yield TextArea(id="hypothesis-input")
269
+ yield Label("Optional operational instruction", classes="form-label")
270
+ yield TextArea(id="instruction-input")
271
+ yield Label("Campaign ID", classes="form-label")
272
+ yield Input(value=self.campaign_default, id="campaign-id")
273
+ yield Label("Output directory", classes="form-label")
274
+ yield Input(
275
+ value=str(Path.cwd() / "artifacts" / self.campaign_default),
276
+ id="output-directory",
277
+ )
278
+ yield Label("Maximum wall time (seconds)", classes="form-label")
279
+ yield Input(value="21600", id="max-wall-seconds", type="number")
280
+ yield Label("Maximum command time (seconds)", classes="form-label")
281
+ yield Input(value="600", id="max-command-seconds", type="number")
282
+ yield Label("Workspace limit (MB)", classes="form-label")
283
+ yield Input(value="512", id="max-workspace-mb", type="number")
284
+ yield Label("Memory limit (MB)", classes="form-label")
285
+ yield Input(value="4096", id="max-memory-mb", type="number")
286
+ yield Label("Installed capabilities", classes="form-label")
287
+ yield Static(self._capability_text(), id="capabilities", classes="panel")
288
+ yield Static("", id="form-error", classes="form-error")
289
+ with Horizontal(classes="toolbar"):
290
+ yield Button("Review contract", id="review", variant="primary")
291
+ yield Button("Back", id="back")
292
+ yield Footer()
293
+
294
+ def _capability_text(self) -> str:
295
+ try:
296
+ descriptors = installed_capability_descriptors()
297
+ except Exception as error: # noqa: BLE001 - operator display of discovery failure
298
+ return f"Capability discovery failed: {error}"
299
+ if not descriptors:
300
+ return "No installed capabilities detected. The Python sandbox remains available."
301
+ lines = []
302
+ for item in descriptors:
303
+ name = item.get("name") or "unnamed"
304
+ version = item.get("version") or ""
305
+ description = item.get("description") or ""
306
+ skill = item.get("skill") or ""
307
+ lines.append(f"• {name} {version}".rstrip())
308
+ if description:
309
+ lines.append(f" {description}")
310
+ if skill:
311
+ lines.append(f" skill: {skill}")
312
+ return "\n".join(lines)
313
+
314
+ def action_back(self) -> None:
315
+ self.app.pop_screen()
316
+
317
+ def action_review(self) -> None:
318
+ request = self.validate()
319
+ if request is None:
320
+ return
321
+ self.app.push_screen(ContractReviewScreen(request))
322
+
323
+ def on_button_pressed(self, event: Button.Pressed) -> None:
324
+ if event.button.id == "review":
325
+ self.action_review()
326
+ elif event.button.id == "back":
327
+ self.action_back()
328
+
329
+ def on_input_changed(self, event: Input.Changed) -> None:
330
+ if event.input.id == "campaign-id" and self.output_follows_campaign:
331
+ campaign = event.value.strip() or self.campaign_default
332
+ self.query_one("#output-directory", Input).value = str(
333
+ Path.cwd() / "artifacts" / campaign
334
+ )
335
+ elif event.input.id == "output-directory":
336
+ campaign = self.query_one("#campaign-id", Input).value.strip()
337
+ expected = str(Path.cwd() / "artifacts" / (campaign or self.campaign_default))
338
+ self.output_follows_campaign = event.value.strip() in {"", expected}
339
+
340
+ def validate(self) -> MVPLaunchRequest | None:
341
+ error_widget = self.query_one("#form-error", Static)
342
+ hypothesis = self.query_one("#hypothesis-input", TextArea).text.strip()
343
+ instruction = self.query_one("#instruction-input", TextArea).text.strip()
344
+ campaign = self.query_one("#campaign-id", Input).value.strip()
345
+ output = self.query_one("#output-directory", Input).value.strip()
346
+ try:
347
+ wall = float(self.query_one("#max-wall-seconds", Input).value)
348
+ command = float(self.query_one("#max-command-seconds", Input).value)
349
+ workspace = int(float(self.query_one("#max-workspace-mb", Input).value))
350
+ memory = int(float(self.query_one("#max-memory-mb", Input).value))
351
+ except ValueError:
352
+ self.last_error = "Resource fields must be positive numbers."
353
+ error_widget.update(self.last_error)
354
+ return None
355
+ if not hypothesis:
356
+ self.last_error = "A root hypothesis is required."
357
+ error_widget.update(self.last_error)
358
+ return None
359
+ if len(hypothesis) < 8:
360
+ self.last_error = "The hypothesis must be at least 8 characters."
361
+ error_widget.update(self.last_error)
362
+ return None
363
+ try:
364
+ campaign = validate_campaign_id(campaign)
365
+ except ValueError as error:
366
+ self.last_error = str(error)
367
+ error_widget.update(self.last_error)
368
+ return None
369
+ if not output:
370
+ self.last_error = "An output directory is required."
371
+ error_widget.update(self.last_error)
372
+ return None
373
+ if min(wall, command, workspace, memory) <= 0:
374
+ self.last_error = "Resource limits must be greater than zero."
375
+ error_widget.update(self.last_error)
376
+ return None
377
+ self.last_error = ""
378
+ error_widget.update("")
379
+ return MVPLaunchRequest(
380
+ hypothesis=hypothesis,
381
+ instruction=instruction or None,
382
+ campaign_id=campaign,
383
+ output_directory=str(Path(output).expanduser()),
384
+ max_wall_seconds=wall,
385
+ max_command_seconds=command,
386
+ max_workspace_mb=workspace,
387
+ max_memory_mb=memory,
388
+ )
389
+
390
+
391
+ class ContractReviewScreen(Screen[None]):
392
+ BINDINGS = [
393
+ Binding("escape", "back", "Back"),
394
+ Binding("enter", "launch", "Launch"),
395
+ ]
396
+
397
+ def __init__(self, request: MVPLaunchRequest) -> None:
398
+ super().__init__()
399
+ self.request = request
400
+ self.last_error = ""
401
+
402
+ def compose(self) -> ComposeResult:
403
+ yield Header(show_clock=True)
404
+ yield Static("Contract review", classes="panel-title")
405
+ yield Static(self._body(), id="contract-body", classes="panel")
406
+ yield Static("", id="launch-error", classes="form-error")
407
+ with Horizontal(classes="toolbar"):
408
+ yield Button("Launch campaign", id="launch", variant="primary")
409
+ yield Button("Back", id="back")
410
+ yield Footer()
411
+
412
+ def _body(self) -> str:
413
+ request = self.request
414
+ instruction = request.instruction or "(none)"
415
+ return "\n".join(
416
+ [
417
+ f"Campaign: {request.campaign_id}",
418
+ f"Output: {Path(request.output_directory).expanduser()}",
419
+ (
420
+ f"Envelope: wall {format_duration(request.max_wall_seconds)}, "
421
+ f"command {int(request.max_command_seconds)} s, "
422
+ f"workspace {request.max_workspace_mb} MB, "
423
+ f"memory {request.max_memory_mb} MB"
424
+ ),
425
+ "",
426
+ "Root hypothesis",
427
+ request.hypothesis,
428
+ "",
429
+ "Operational instruction",
430
+ instruction,
431
+ "",
432
+ "Launch will write operator_input/hypothesis.txt and invoke "
433
+ "--hypothesis-file. No shell string is constructed. The durable "
434
+ "manifest, transcript, ledger, and report remain authoritative.",
435
+ ]
436
+ )
437
+
438
+ def action_back(self) -> None:
439
+ self.app.pop_screen()
440
+
441
+ def action_launch(self) -> None:
442
+ error_widget = self.query_one("#launch-error", Static)
443
+ try:
444
+ plan = materialize_operator_input(self.request)
445
+ campaign = start_managed_campaign(plan)
446
+ except Exception as error: # noqa: BLE001 - surface launch failure to operator
447
+ self.last_error = f"Launch failed: {error}"
448
+ error_widget.update(self.last_error)
449
+ return
450
+ _app(self).attach_managed(campaign)
451
+ while len(self.app.screen_stack) > 1:
452
+ self.app.pop_screen()
453
+ self.app.push_screen(DashboardScreen(plan.output_directory))
454
+
455
+ def on_button_pressed(self, event: Button.Pressed) -> None:
456
+ if event.button.id == "launch":
457
+ self.action_launch()
458
+ elif event.button.id == "back":
459
+ self.action_back()
460
+
461
+
462
+ class DashboardScreen(Screen[None]):
463
+ BINDINGS = [
464
+ Binding("c", "cancel", "Cancel", priority=True),
465
+ Binding("p", "pause", "Pause", priority=True),
466
+ Binding("r", "resume", "Resume", priority=True),
467
+ Binding("l", "logs", "Logs", priority=True),
468
+ Binding("a", "artifacts", "Artifacts", priority=True),
469
+ Binding("v", "audit_ledger", "Audit ledger", priority=True),
470
+ Binding("enter", "claim_detail", "Claim"),
471
+ Binding("q", "leave", "Leave UI", priority=True),
472
+ ]
473
+
474
+ def __init__(self, run_directory: str | Path) -> None:
475
+ super().__init__()
476
+ self.run_directory = Path(run_directory).expanduser().resolve()
477
+ self.monitor = MVPRunMonitor(self.run_directory)
478
+ self.snapshot: MVPRunSnapshot | None = None
479
+ self.last_cancel_message = ""
480
+ self.cancel_in_progress = False
481
+ self.last_control_message = ""
482
+ self.selected_claim_id: str | None = None
483
+ self.selected_hypothesis_id: str | None = None
484
+ self.selected_validation_id: str | None = None
485
+ self.hypothesis_rows: tuple[ClaimTreeRow, ...] = ()
486
+ self.validation_rows: tuple[ClaimTreeRow, ...] = ()
487
+ self._hypothesis_labels: tuple[str, ...] | None = None
488
+ self._validation_labels: tuple[str, ...] | None = None
489
+ self._rendering_claim_views = False
490
+
491
+ def compose(self) -> ComposeResult:
492
+ yield Header(show_clock=True)
493
+ with VerticalScroll():
494
+ yield Static("", id="status-line")
495
+ yield Static("", id="clock-line")
496
+ yield Static("", id="model-line")
497
+ yield Static("", id="usage-line")
498
+ yield Static("Hypothesis tree", classes="panel-title")
499
+ yield ListView(id="hypothesis-tree")
500
+ yield Static(
501
+ "Validation claims for selected hypothesis",
502
+ id="validation-title",
503
+ classes="panel-title",
504
+ )
505
+ yield ListView(id="validation-claims")
506
+ yield Static("Current action", classes="panel-title")
507
+ yield Static("", id="current-action", classes="panel")
508
+ yield Static("Recent activity", classes="panel-title")
509
+ yield Static("", id="activity", classes="panel")
510
+ yield Static("Terminal report", classes="panel-title")
511
+ yield Static("", id="final-answer", classes="panel")
512
+ yield Footer()
513
+
514
+ def on_mount(self) -> None:
515
+ self.refresh_snapshot()
516
+ self.set_interval(0.5, self.refresh_snapshot)
517
+
518
+ def refresh_snapshot(self) -> None:
519
+ try:
520
+ snapshot = self.monitor.snapshot()
521
+ except (FileNotFoundError, NotADirectoryError, OSError) as error:
522
+ self.query_one("#status-line", Static).update(f"Cannot read run directory: {error}")
523
+ return
524
+ self.snapshot = snapshot
525
+ live = _app(self).live_identity_for(self.run_directory)
526
+ phase_text = "RUNNING" if live else snapshot.phase_label
527
+ campaign = snapshot.identity.campaign_id or self.run_directory.name
528
+ status = self.query_one("#status-line", Static)
529
+ status.update(f"Campaign: {campaign} {phase_text}")
530
+ status.set_class(False, "phase-running")
531
+ status.set_classes(_phase_class(phase_text))
532
+ elapsed = format_duration(snapshot.elapsed_wall_seconds)
533
+ budget = format_duration(snapshot.configured_wall_seconds)
534
+ estimate = "~" if snapshot.elapsed_is_estimate else ""
535
+ heartbeat = "heartbeat --"
536
+ if snapshot.latest_heartbeat is not None:
537
+ heartbeat = f"heartbeat {format_age(snapshot.latest_heartbeat.age_seconds)}"
538
+ self.query_one("#clock-line", Static).update(
539
+ f"elapsed {estimate}{elapsed} / {budget} {heartbeat}"
540
+ )
541
+ model = snapshot.last_model or "-"
542
+ capability = snapshot.last_capability or "-"
543
+ if snapshot.current_action and snapshot.current_action.capability:
544
+ capability = snapshot.current_action.capability
545
+ self.query_one("#model-line", Static).update(
546
+ f"model: {model} capability: {capability}"
547
+ )
548
+ usage = snapshot.token_usage.label
549
+ if snapshot.pending_control:
550
+ usage = f"{usage} pending {snapshot.pending_control}"
551
+ self.query_one("#usage-line", Static).update(usage)
552
+ self._render_claim_views(snapshot)
553
+ self.query_one("#current-action", Static).update(self._current_action_text(snapshot, live))
554
+ events = snapshot.recent_events[-8:]
555
+ activity = "\n".join(event.summary for event in events) or "(no transcript events yet)"
556
+ self.query_one("#activity", Static).update(activity)
557
+ if snapshot.report is None:
558
+ self.query_one("#final-answer", Static).update(
559
+ "No terminal report yet. Incomplete is not a scientific result."
560
+ )
561
+ else:
562
+ notes = "\n".join(snapshot.report.finish_claim_notes)
563
+ answer = snapshot.report.final_answer.strip() or "(empty final answer)"
564
+ body = f"status: {snapshot.report.status}\n{answer}"
565
+ if notes:
566
+ body += f"\n\nFinish notes\n{notes}"
567
+ self.query_one("#final-answer", Static).update(body)
568
+
569
+ def _render_claim_views(self, snapshot: MVPRunSnapshot) -> None:
570
+ self._rendering_claim_views = True
571
+ try:
572
+ self.hypothesis_rows = build_hypothesis_tree(snapshot.claims)
573
+ selected = self.selected_hypothesis_id
574
+ available = {row.claim.id for row in self.hypothesis_rows}
575
+ if selected not in available:
576
+ active = next((claim for claim in snapshot.claims if claim.active), None)
577
+ selected = (
578
+ scientific_ancestor_id(snapshot.claims, active.id)
579
+ if active is not None
580
+ else None
581
+ )
582
+ if selected not in available:
583
+ selected = self.hypothesis_rows[0].claim.id if self.hypothesis_rows else None
584
+ self.selected_hypothesis_id = selected
585
+
586
+ tree = self.query_one("#hypothesis-tree", ListView)
587
+ if not self.hypothesis_rows:
588
+ hypothesis = snapshot.identity.hypothesis or "hypothesis not yet recorded"
589
+ labels = (f"(claim ledger not initialized) {hypothesis}",)
590
+ selected_index = 0
591
+ else:
592
+ labels = tuple(
593
+ hypothesis_row_label(row) for row in self.hypothesis_rows
594
+ )
595
+ selected_index = next(
596
+ (
597
+ index
598
+ for index, row in enumerate(self.hypothesis_rows)
599
+ if row.claim.id == selected
600
+ ),
601
+ 0,
602
+ )
603
+ self._hypothesis_labels = _sync_list_labels(
604
+ tree,
605
+ labels,
606
+ self._hypothesis_labels,
607
+ )
608
+ if tree.index != selected_index:
609
+ tree.index = selected_index
610
+ self._render_validation_claims(snapshot)
611
+ finally:
612
+ self._rendering_claim_views = False
613
+
614
+ def _render_validation_claims(self, snapshot: MVPRunSnapshot) -> None:
615
+ listing = self.query_one("#validation-claims", ListView)
616
+ selected = self.selected_hypothesis_id
617
+ title = "Validation claims for selected hypothesis"
618
+ if selected is None:
619
+ self.validation_rows = ()
620
+ labels = ("(select a hypothesis)",)
621
+ selected_index = 0
622
+ else:
623
+ title += f" · {selected}"
624
+ self.validation_rows = build_validation_tree(snapshot.claims, selected)
625
+ available = {row.claim.id for row in self.validation_rows}
626
+ if self.selected_validation_id not in available:
627
+ self.selected_validation_id = (
628
+ self.validation_rows[0].claim.id if self.validation_rows else None
629
+ )
630
+ if not self.validation_rows:
631
+ labels = ("(no linked validation claims)",)
632
+ selected_index = 0
633
+ else:
634
+ labels = tuple(
635
+ validation_row_label(row) for row in self.validation_rows
636
+ )
637
+ selected_index = next(
638
+ (
639
+ index
640
+ for index, row in enumerate(self.validation_rows)
641
+ if row.claim.id == self.selected_validation_id
642
+ ),
643
+ 0,
644
+ )
645
+ self._validation_labels = _sync_list_labels(
646
+ listing,
647
+ labels,
648
+ self._validation_labels,
649
+ )
650
+ if listing.index != selected_index:
651
+ listing.index = selected_index
652
+ self.query_one("#validation-title", Static).update(title)
653
+
654
+ def _current_action_text(self, snapshot: MVPRunSnapshot, live: bool) -> str:
655
+ if snapshot.current_action is None:
656
+ if snapshot.phase in {
657
+ RunPhase.COMPLETED,
658
+ RunPhase.CANCELLED,
659
+ RunPhase.PROVIDER_FAILED,
660
+ RunPhase.BUDGET_EXHAUSTED,
661
+ }:
662
+ return "Campaign has a terminal report."
663
+ if snapshot.phase is RunPhase.PAUSED:
664
+ return "Paused at an action boundary. Press r to resume the same contract."
665
+ if live:
666
+ return "Waiting for the next durable action."
667
+ return "No pending action. Process liveness is unknown unless this UI launched it."
668
+ action = snapshot.current_action
669
+ details = [f"iteration={action.iteration}"]
670
+ if action.stage:
671
+ details.append(f"stage={action.stage}")
672
+ if snapshot.latest_heartbeat and snapshot.latest_heartbeat.elapsed_wall_seconds is not None:
673
+ details.append(f"elapsed={int(snapshot.latest_heartbeat.elapsed_wall_seconds)} s")
674
+ details.append(f"workspace={format_bytes(snapshot.workspace_bytes)}")
675
+ return f"{action.description}\n" + ", ".join(details)
676
+
677
+ def action_leave(self) -> None:
678
+ _app(self).detach_managed()
679
+ if len(self.app.screen_stack) > 1:
680
+ self.app.pop_screen()
681
+ else:
682
+ self.app.exit()
683
+
684
+ def action_cancel(self) -> None:
685
+ if self.snapshot is not None and self.snapshot.phase in {
686
+ RunPhase.COMPLETED,
687
+ RunPhase.CANCELLED,
688
+ RunPhase.PROVIDER_FAILED,
689
+ RunPhase.BUDGET_EXHAUSTED,
690
+ }:
691
+ self.last_cancel_message = "campaign already has a terminal report"
692
+ self.notify(self.last_cancel_message)
693
+ return
694
+ if self.cancel_in_progress:
695
+ self.notify("cancellation is already in progress")
696
+ return
697
+ if not _app(self).live_identity_for(self.run_directory):
698
+ self.last_cancel_message = (
699
+ "cancel is only offered for a process this UI launched or a verified "
700
+ "supervisor record; this attach is read-only"
701
+ )
702
+ self.notify(self.last_cancel_message)
703
+ return
704
+ self.cancel_in_progress = True
705
+ self.last_cancel_message = "cancellation requested"
706
+ self.notify(self.last_cancel_message)
707
+ self._cancel_campaign()
708
+
709
+ @work(thread=True, exclusive=True, group="campaign-cancel")
710
+ def _cancel_campaign(self) -> None:
711
+ try:
712
+ message = _app(self).cancel_run(self.run_directory)
713
+ except Exception as error:
714
+ message = f"cancellation failed: {type(error).__name__}: {error}"
715
+ self.app.call_from_thread(self._finish_cancel, message)
716
+
717
+ def _finish_cancel(self, message: str) -> None:
718
+ self.cancel_in_progress = False
719
+ self.last_cancel_message = message
720
+ self.notify(message)
721
+ self.refresh_snapshot()
722
+
723
+ def action_pause(self) -> None:
724
+ if self.snapshot is not None and self.snapshot.phase in {
725
+ RunPhase.COMPLETED,
726
+ RunPhase.CANCELLED,
727
+ RunPhase.PROVIDER_FAILED,
728
+ RunPhase.BUDGET_EXHAUSTED,
729
+ RunPhase.PAUSED,
730
+ }:
731
+ self.last_control_message = "campaign is not running"
732
+ self.notify(self.last_control_message)
733
+ return
734
+ self.last_control_message = request_verified_pause(
735
+ self.run_directory,
736
+ source="tui",
737
+ )
738
+ self.notify(self.last_control_message)
739
+ self.refresh_snapshot()
740
+
741
+ def action_resume(self) -> None:
742
+ try:
743
+ plan = prepare_resume(self.run_directory)
744
+ except ResumeError as error:
745
+ self.last_control_message = str(error)
746
+ self.notify(self.last_control_message)
747
+ return
748
+ try:
749
+ campaign = start_managed_campaign(plan)
750
+ except Exception as error: # noqa: BLE001 - surface resume failure
751
+ self.last_control_message = f"resume failed: {error}"
752
+ self.notify(self.last_control_message)
753
+ return
754
+ _app(self).attach_managed(campaign)
755
+ self.last_control_message = f"resumed pid={campaign.identity.pid}"
756
+ self.notify(self.last_control_message)
757
+ self.monitor = MVPRunMonitor(self.run_directory)
758
+ self.refresh_snapshot()
759
+
760
+ def action_logs(self) -> None:
761
+ self.app.push_screen(LogsScreen(self.run_directory))
762
+
763
+ def action_artifacts(self) -> None:
764
+ self.app.push_screen(ArtifactsScreen(self.run_directory))
765
+
766
+ def action_audit_ledger(self) -> None:
767
+ claims = self.snapshot.claims if self.snapshot is not None else ()
768
+ self.app.push_screen(AuditLedgerScreen(claims))
769
+
770
+ def action_claim_detail(self) -> None:
771
+ focused = self.focused
772
+ if not isinstance(focused, ListView):
773
+ return
774
+ index = focused.index
775
+ if index is None:
776
+ return
777
+ claim = self._claim_at(focused.id, index)
778
+ if claim is None:
779
+ return
780
+ self._open_claim(claim)
781
+
782
+ def _claim_at(self, listing_id: str | None, index: int) -> ClaimSummary | None:
783
+ if listing_id == "hypothesis-tree" and index < len(self.hypothesis_rows):
784
+ return self.hypothesis_rows[index].claim
785
+ if listing_id == "validation-claims" and index < len(self.validation_rows):
786
+ return self.validation_rows[index].claim
787
+ return None
788
+
789
+ def _open_claim(self, claim: ClaimSummary) -> None:
790
+ self.selected_claim_id = claim.id
791
+ self.app.push_screen(ClaimDetailScreen(claim))
792
+
793
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
794
+ claim = self._claim_at(event.list_view.id, event.index)
795
+ if claim is not None:
796
+ self._open_claim(claim)
797
+
798
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
799
+ if self._rendering_claim_views or event.list_view.index is None:
800
+ return
801
+ index = event.list_view.index
802
+ if event.list_view.id == "hypothesis-tree" and index < len(
803
+ self.hypothesis_rows
804
+ ):
805
+ selected = self.hypothesis_rows[index].claim.id
806
+ if selected != self.selected_hypothesis_id:
807
+ self.selected_hypothesis_id = selected
808
+ self.selected_validation_id = None
809
+ if self.snapshot is not None:
810
+ self._render_validation_claims(self.snapshot)
811
+ elif event.list_view.id == "validation-claims" and index < len(
812
+ self.validation_rows
813
+ ):
814
+ self.selected_validation_id = self.validation_rows[index].claim.id
815
+
816
+
817
+ class HelpScreen(ModalScreen[None]):
818
+ BINDINGS = [Binding("escape", "close", "Close"), Binding("q", "close", "Close")]
819
+
820
+ def compose(self) -> ComposeResult:
821
+ yield Static("Keyboard help", classes="panel-title")
822
+ yield Static(
823
+ "\n".join(
824
+ [
825
+ "The terminal UI is a projection of durable records.",
826
+ "mvp_report.json, hypothesis_ledger.json, and transcript.jsonl "
827
+ "remain authoritative.",
828
+ "There is no fabricated scientific completion percentage.",
829
+ "",
830
+ "[n] new run [enter] attach or open claim",
831
+ "[c] cancel verified process; writes a cancelled report",
832
+ "[p] pause next action boundary; does not SIGSTOP",
833
+ "[r] resume repeat the stored launch contract",
834
+ "[l] controller log [a] artifacts inside the run directory",
835
+ "[v] audit ledger every scientific and validation claim",
836
+ "[q] leave UI detaches; does not cancel or pause",
837
+ "[?] help [esc] close this panel",
838
+ "",
839
+ "The hypothesis tree contains scientific claims only. Instrument,",
840
+ "diagnostic, and control claims follow the selected hypothesis.",
841
+ "status, watch, pause, and resume work without this optional extra.",
842
+ "Token counts come from provider usage on assistant transcript rows.",
843
+ "Install the extra with: uv sync --extra tui",
844
+ ]
845
+ ),
846
+ id="help-body",
847
+ classes="panel",
848
+ )
849
+
850
+ def action_close(self) -> None:
851
+ self.app.pop_screen()
852
+
853
+
854
+ class ClaimDetailScreen(ModalScreen[None]):
855
+ BINDINGS = [Binding("escape", "close", "Close"), Binding("q", "close", "Close")]
856
+
857
+ def __init__(self, claim: ClaimSummary) -> None:
858
+ super().__init__()
859
+ self.claim = claim
860
+
861
+ def compose(self) -> ComposeResult:
862
+ claim_type = "Hypothesis" if self.claim.kind == "scientific" else "Validation claim"
863
+ yield Static(f"{claim_type} · {self.claim.id}", classes="panel-title")
864
+ yield Static(
865
+ _claim_detail_body(self.claim),
866
+ id="claim-detail",
867
+ classes="panel",
868
+ )
869
+
870
+ def action_close(self) -> None:
871
+ self.app.pop_screen()
872
+
873
+
874
+ class AuditLedgerScreen(ModalScreen[None]):
875
+ """Complete claim ledger retained alongside the human-first projections."""
876
+
877
+ BINDINGS = [
878
+ Binding("escape", "close", "Close"),
879
+ Binding("q", "close", "Close"),
880
+ Binding("enter", "preview", "Inspect"),
881
+ ]
882
+
883
+ def __init__(self, claims: tuple[ClaimSummary, ...]) -> None:
884
+ super().__init__()
885
+ self.claims = claims
886
+
887
+ def compose(self) -> ComposeResult:
888
+ yield Static("Complete audit ledger", classes="panel-title")
889
+ items = (
890
+ [ListItem(Label(audit_row_label(claim))) for claim in self.claims]
891
+ if self.claims
892
+ else [ListItem(Label("(no claims recorded)"))]
893
+ )
894
+ yield ListView(*items, id="audit-claims")
895
+ yield Static(
896
+ "Select a claim to inspect its typed relation and evidence counts.",
897
+ id="audit-claim-detail",
898
+ classes="panel",
899
+ )
900
+
901
+ def action_preview(self) -> None:
902
+ listing = self.query_one("#audit-claims", ListView)
903
+ index = listing.index
904
+ if index is None or index >= len(self.claims):
905
+ return
906
+ self.query_one("#audit-claim-detail", Static).update(
907
+ _claim_detail_body(self.claims[index])
908
+ )
909
+
910
+ def action_close(self) -> None:
911
+ self.app.pop_screen()
912
+
913
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
914
+ del event
915
+ self.action_preview()
916
+
917
+
918
+ class LogsScreen(ModalScreen[None]):
919
+ BINDINGS = [Binding("escape", "close", "Close"), Binding("q", "close", "Close")]
920
+
921
+ def __init__(self, run_directory: Path) -> None:
922
+ super().__init__()
923
+ self.run_directory = run_directory
924
+
925
+ def compose(self) -> ComposeResult:
926
+ yield Static("Controller log", classes="panel-title")
927
+ yield Static(self._load(), id="log-body", classes="panel")
928
+
929
+ def _load(self) -> str:
930
+ path = contained_path(self.run_directory, "controller.log")
931
+ if path is None or not path.is_file():
932
+ return (
933
+ "No controller.log in this run directory. The TUI writes this file "
934
+ "only for campaigns it launches."
935
+ )
936
+ try:
937
+ data = path.read_bytes()[-16_384:]
938
+ except OSError as error:
939
+ return f"Unable to read controller.log: {error}"
940
+ return data.decode("utf-8", errors="replace") or "(empty log)"
941
+
942
+ def action_close(self) -> None:
943
+ self.app.pop_screen()
944
+
945
+
946
+ class ArtifactsScreen(ModalScreen[None]):
947
+ BINDINGS = [
948
+ Binding("escape", "close", "Close"),
949
+ Binding("q", "close", "Close"),
950
+ Binding("enter", "preview", "Preview"),
951
+ ]
952
+
953
+ def __init__(self, run_directory: Path) -> None:
954
+ super().__init__()
955
+ self.run_directory = run_directory
956
+ self.entries = list_contained_artifacts(run_directory)
957
+
958
+ def compose(self) -> ComposeResult:
959
+ yield Static("Run artifacts (contained paths only)", classes="panel-title")
960
+ items = (
961
+ [
962
+ ListItem(
963
+ Label(
964
+ f"{entry.relative_path} {format_bytes(entry.bytes)} "
965
+ f"{entry.kind}"
966
+ )
967
+ )
968
+ for entry in self.entries
969
+ ]
970
+ if self.entries
971
+ else [ListItem(Label("(no files)"))]
972
+ )
973
+ yield ListView(*items, id="artifact-list")
974
+ yield Static(
975
+ "Select a contained file to preview text.",
976
+ id="artifact-preview",
977
+ classes="panel",
978
+ )
979
+
980
+ def action_preview(self) -> None:
981
+ listing = self.query_one("#artifact-list", ListView)
982
+ index = listing.index
983
+ if index is None or index >= len(self.entries):
984
+ return
985
+ entry = self.entries[index]
986
+ preview = self.query_one("#artifact-preview", Static)
987
+ path = contained_path(self.run_directory, entry.relative_path)
988
+ if path is None:
989
+ preview.update("Refusing to open a path outside the run directory.")
990
+ return
991
+ if entry.kind == "symlink" or path.is_symlink():
992
+ preview.update("Symlink; content is not opened.")
993
+ return
994
+ if not path.is_file():
995
+ preview.update("Not a regular file.")
996
+ return
997
+ if entry.bytes > 32_768:
998
+ preview.update(
999
+ f"{entry.relative_path}: {format_bytes(entry.bytes)} (too large to preview)"
1000
+ )
1001
+ return
1002
+ try:
1003
+ text = path.read_text(errors="replace")
1004
+ except OSError as error:
1005
+ preview.update(f"Unable to read file: {error}")
1006
+ return
1007
+ preview.update(text[:4000] if text else "(empty file)")
1008
+
1009
+ def action_close(self) -> None:
1010
+ self.app.pop_screen()
1011
+
1012
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
1013
+ del event
1014
+ self.action_preview()