Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m30s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m34s
Compose Smoke Test / test-compose (push) Failing after 12s
Playwright Tests / merge-reports (push) Failing after 2m49s
`--format jsonl` existed, but `_cell` ran json.dumps at row-build time, before a format was chosen — so a nested value was a string by then and jsonl only re-escaped it, leaving a consumer against csv's 128KB field limit either way. Stringifying moved to the csv writer, so csv is byte-identical and jsonl carries json. The runs TUI followed, or a record would draw as a Python repr. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
237 lines
8.7 KiB
Python
237 lines
8.7 KiB
Python
"""Runs beside each other: one metric's curves, and what differs between them.
|
|
|
|
The engine already answers both halves. `GET /runs/series/compare` returns the
|
|
chart's own series shape for any number of runs, and `GET /runs/export/runs`
|
|
returns one row per run with every input and final number the selection
|
|
recorded as its own column — the table this screen keeps the varying half of.
|
|
So nothing here computes what a route already knows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from rich.text import Text
|
|
from textual import work
|
|
from textual.app import ComposeResult
|
|
from textual.containers import Horizontal
|
|
from textual.screen import Screen
|
|
from textual.widgets import DataTable, Footer, Header, Select, Static
|
|
|
|
from fluksio.sdk.cli import _dur, metric_names
|
|
from fluksio.sdk.client import Client
|
|
from fluksio.tui.chart import MAX_SERIES, Curves, legend
|
|
|
|
#: The axes every comparison offers, before the run's own metrics are added.
|
|
STEP_AXIS, TIME_AXIS = "step", "time"
|
|
|
|
#: What the columns of `export runs` are called that are not a run's own.
|
|
PARAM, METRIC = "param.", "metric."
|
|
|
|
#: The statuses that mean a run's numbers are not final yet.
|
|
RUNNING = frozenset({"queued", "running"})
|
|
|
|
#: How often a curve is re-read while a run in the selection is still going.
|
|
#: Metric points are not published on the event bus — the run's sink writes
|
|
#: them — so the socket makes the *table* live and this makes the *curve* live.
|
|
LIVE_S = 1.0
|
|
|
|
|
|
def _cell(value: Any) -> str:
|
|
"""A reading narrow enough for a column, at the precision one reads at.
|
|
|
|
Four significant figures, which is what the browser's legend settles on:
|
|
the seventeen digits a float prints are a column nobody can scan.
|
|
"""
|
|
if isinstance(value, float):
|
|
return f"{value:.4g}"
|
|
if isinstance(value, (dict, list)):
|
|
# An export keeps a record a value now, and python's repr of one is
|
|
# not what anybody reading a run wrote down.
|
|
return json.dumps(value)
|
|
return "" if value is None else str(value)
|
|
|
|
|
|
def varying(rows: list[dict[str, Any]], prefix: str) -> list[str]:
|
|
"""The prefixed columns whose value is not the same in every row.
|
|
|
|
What a reader is looking for across a page of runs is where they differ;
|
|
a sweep over one parameter should not draw twelve identical columns.
|
|
"""
|
|
if len(rows) < 2:
|
|
return sorted(key for key in rows[0] if key.startswith(prefix)) if rows else []
|
|
return sorted(
|
|
key
|
|
for key in rows[0]
|
|
if key.startswith(prefix) and len({str(row.get(key)) for row in rows}) > 1
|
|
)
|
|
|
|
|
|
class CompareScreen(Screen[None]):
|
|
"""One metric across the picked runs, over the table of what differs."""
|
|
|
|
CSS = """
|
|
#head { padding: 0 1; }
|
|
#pickers { height: 3; padding: 0 1; }
|
|
#pickers Select { width: 34; }
|
|
#legend { padding: 0 1; height: auto; }
|
|
#diff { height: auto; max-height: 12; }
|
|
"""
|
|
|
|
BINDINGS = [
|
|
("escape", "app.pop_screen", "back"),
|
|
("r", "reload", "reload"),
|
|
]
|
|
|
|
def __init__(self, client: Client, ids: list[str]) -> None:
|
|
super().__init__()
|
|
self.client = client
|
|
self.ids = ids
|
|
self.metric = ""
|
|
self.x = STEP_AXIS
|
|
self.names: list[str] = []
|
|
#: What the metric picker is currently offering. Rebuilding it on every
|
|
#: refresh would shut a dropdown under whoever opened it.
|
|
self.names_shown: list[str] = []
|
|
self.live: Any = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
yield Static(id="head")
|
|
with Horizontal(id="pickers"):
|
|
yield Select([], id="metric", prompt="metric", allow_blank=True)
|
|
yield Select(
|
|
[("step", STEP_AXIS), ("time (s)", TIME_AXIS)],
|
|
id="x",
|
|
value=STEP_AXIS,
|
|
allow_blank=False,
|
|
)
|
|
yield Curves(id="chart")
|
|
yield Static(id="legend")
|
|
table: DataTable[Any] = DataTable(id="diff", cursor_type="row")
|
|
yield table
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
self.title = f"{len(self.ids)} runs"
|
|
self.query_one("#head", Static).update("Reading these runs\u2026")
|
|
self.read_table()
|
|
|
|
def action_reload(self) -> None:
|
|
self.read_table()
|
|
|
|
def on_select_changed(self, event: Select.Changed) -> None:
|
|
if event.value is Select.BLANK:
|
|
return
|
|
if event.select.id == "metric":
|
|
self.metric = str(event.value)
|
|
else:
|
|
self.x = str(event.value)
|
|
self.read_curves()
|
|
|
|
# -- reading ---------------------------------------------------------------
|
|
|
|
@work(thread=True, exclusive=True, group="table")
|
|
def read_table(self) -> None:
|
|
try:
|
|
rows = self.client.export_runs(ids=self.ids)
|
|
names = metric_names(self.client, self.ids)
|
|
except Exception as exc: # noqa: BLE001 — the screen reports it
|
|
self.app.call_from_thread(self.show_error, exc)
|
|
return
|
|
self.app.call_from_thread(self.show_table, rows, names)
|
|
|
|
@work(thread=True, exclusive=True, group="curves")
|
|
def read_curves(self) -> None:
|
|
if not self.metric:
|
|
return
|
|
try:
|
|
answer = self.client.compare(self.ids[:MAX_SERIES], self.metric, self.x)
|
|
except Exception as exc: # noqa: BLE001 — the screen reports it
|
|
self.app.call_from_thread(self.show_error, exc)
|
|
return
|
|
self.app.call_from_thread(self.show_curves, answer)
|
|
|
|
# -- drawing ---------------------------------------------------------------
|
|
|
|
def show_error(self, exc: Exception) -> None:
|
|
self.query_one("#head", Static).update(
|
|
Text(f"{type(exc).__name__}: {exc}", style="red")
|
|
)
|
|
|
|
def show_table(self, rows: list[dict[str, Any]], names: list[str]) -> None:
|
|
self.names = names
|
|
flow = str(rows[0].get("flow", "")) if rows else ""
|
|
drawn = (
|
|
f" {MAX_SERIES} of {len(self.ids)} drawn"
|
|
if len(self.ids) > MAX_SERIES
|
|
else ""
|
|
)
|
|
self.query_one("#head", Static).update(
|
|
f"{len(self.ids)} runs \u00b7 {flow}{drawn}"
|
|
)
|
|
|
|
picker = self.query_one("#metric", Select)
|
|
if names and names != self.names_shown:
|
|
self.names_shown = list(names)
|
|
picker.set_options([(name, name) for name in names])
|
|
self.metric = self.metric if self.metric in names else names[0]
|
|
picker.value = self.metric
|
|
axes = self.query_one("#x", Select)
|
|
axes.set_options(
|
|
[("step", STEP_AXIS), ("time (s)", TIME_AXIS)]
|
|
+ [(name, name) for name in names if name != self.metric]
|
|
)
|
|
axes.value = (
|
|
self.x if self.x in {STEP_AXIS, TIME_AXIS, *names} else STEP_AXIS
|
|
)
|
|
|
|
table = self.query_one("#diff", DataTable)
|
|
table.clear(columns=True)
|
|
params = varying(rows, PARAM)
|
|
scores = sorted(
|
|
key for key in (rows[0] if rows else {}) if key.startswith(METRIC)
|
|
)
|
|
# Not a parameter, but it is part of what produced the number, and in
|
|
# a sweep it is sometimes the only thing that moved.
|
|
seeds = len({str(row.get("seed")) for row in rows}) > 1
|
|
table.add_columns(
|
|
"run",
|
|
"status",
|
|
"took",
|
|
*(["seed"] if seeds else []),
|
|
*(key[len(PARAM) :] for key in params),
|
|
*(key[len(METRIC) :] for key in scores),
|
|
)
|
|
for row in rows:
|
|
table.add_row(
|
|
str(row.get("id", ""))[-8:],
|
|
str(row.get("status", "")),
|
|
_dur(row.get("duration_ms")),
|
|
*([_cell(row.get("seed"))] if seeds else []),
|
|
*(_cell(row.get(key)) for key in params),
|
|
*(_cell(row.get(key)) for key in scores),
|
|
)
|
|
# A curve only grows while its run does, and metric points are not on
|
|
# the event bus — so this is the one thing the screen polls for.
|
|
self.keep_live(any(str(row.get("status")) in RUNNING for row in rows))
|
|
self.read_curves()
|
|
|
|
def show_curves(self, answer: dict[str, Any]) -> None:
|
|
lines = [
|
|
(str(line.get("label", "")), line.get("points") or [])
|
|
for line in answer.get("lines") or []
|
|
]
|
|
self.query_one("#chart", Curves).show(lines, str(answer.get("x") or ""))
|
|
self.query_one("#legend", Static).update(
|
|
legend(label for label, _ in lines[:MAX_SERIES])
|
|
)
|
|
|
|
def keep_live(self, running: bool) -> None:
|
|
if running and self.live is None:
|
|
self.live = self.set_interval(LIVE_S, self.read_curves)
|
|
elif not running and self.live is not None:
|
|
self.live.stop()
|
|
self.live = None
|