Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 18m28s
Playwright Tests / test-playwright (2, 2) (push) Successful in 5m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m32s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 5m50s
`space` ticks runs on the serve dashboard's table and `enter` compares them: one metric in braille, five distinct hues, and the table of what actually differs under it. Both halves are routes that already existed — the browser's own comparison endpoint for the curves, the runs export for the table, whose input columns are filtered to the ones that vary. The palette is hue rather than the web's lightness ramp on purpose: five steps of one brand hue collapse to a single colour on a 16-colour tty. The screen also subscribes to the engine's event bus over the same websocket a browser uses, so a run that starts and finishes inside a tick is seen rather than only recorded. `a` lists what a run left behind and fetches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SmyLMSqcJQ8tUL2qLj21s
213 lines
7.7 KiB
Python
213 lines
7.7 KiB
Python
"""Curves at a terminal, drawn with braille.
|
||
|
||
A terminal cell holds 2x4 braille dots, so an 80x20 pane is a 160x80 plot —
|
||
enough resolution that a training curve reads as a curve rather than as a bar
|
||
chart. Nothing is imported for this: a plotting library would be two
|
||
dependencies on a package whose every dependency is argued for in
|
||
`pyproject.toml`, for something a screenful of arithmetic does.
|
||
|
||
The colours are deliberately not the web's. `--chart-1…5` step one brand hue
|
||
by lightness, which is right against a designed surface and unreadable in a
|
||
terminal — five steps of one hue collapse to one colour on a 16-colour tty.
|
||
Here identity is carried by hue, and the legend names every series anyway.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterable, Iterator, Sequence
|
||
from typing import Any
|
||
|
||
from rich.text import Text
|
||
from textual.widget import Widget
|
||
|
||
#: One run's readings: a label, and `[[x, y], ...]` as the engine answers.
|
||
Series = tuple[str, Sequence[Sequence[float]]]
|
||
|
||
#: The cap the web draws at, for the same reason: past five, a legend is a
|
||
#: puzzle. A sweep of twenty wants faceting, not a sixth colour.
|
||
MAX_SERIES = 5
|
||
|
||
#: Distinct hues, and the bright half of the ANSI sixteen so they hold up on a
|
||
#: light terminal as well as a dark one.
|
||
SERIES_STYLES = (
|
||
"bright_cyan",
|
||
"bright_magenta",
|
||
"bright_yellow",
|
||
"bright_green",
|
||
"bright_red",
|
||
)
|
||
|
||
#: `⠀`, and the dot bits of the cell above it, by (column, row) within it.
|
||
BRAILLE = 0x2800
|
||
DOTS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
|
||
|
||
#: What the y tick labels take, and what the x axis and its labels take below.
|
||
GUTTER = 9
|
||
AXIS_ROWS = 2
|
||
|
||
|
||
def _num(value: float) -> str:
|
||
"""A tick label short enough to sit in the gutter."""
|
||
if value and (abs(value) >= 1e5 or abs(value) < 1e-3):
|
||
return f"{value:.1e}"
|
||
return f"{value:.4g}"
|
||
|
||
|
||
def shorten(label: str) -> str:
|
||
"""A run's label, with the id cut to the tail that identifies it.
|
||
|
||
The engine answers `<run id> (seed 3)`; a terminal wants the eight
|
||
characters people actually read it by, and the seed kept.
|
||
"""
|
||
head, space, rest = label.partition(" ")
|
||
return head[-8:] + space + rest
|
||
|
||
|
||
def _segment(x0: int, y0: int, x1: int, y1: int) -> Iterator[tuple[int, int]]:
|
||
"""Every dot along a straight line between two readings.
|
||
|
||
Points alone would draw a dotted cloud: forty readings across a
|
||
two-hundred dot axis touch one dot in five.
|
||
"""
|
||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||
step_x = 1 if x0 < x1 else -1
|
||
step_y = 1 if y0 < y1 else -1
|
||
error = dx + dy
|
||
while True:
|
||
yield x0, y0
|
||
if x0 == x1 and y0 == y1:
|
||
return
|
||
doubled = 2 * error
|
||
if doubled >= dy:
|
||
error += dy
|
||
x0 += step_x
|
||
if doubled <= dx:
|
||
error += dx
|
||
y0 += step_y
|
||
|
||
|
||
def _bounds(lines: Sequence[Series]) -> tuple[float, float, float, float]:
|
||
xs = [point[0] for _, points in lines for point in points]
|
||
ys = [point[1] for _, points in lines for point in points]
|
||
return min(xs), max(xs), min(ys), max(ys)
|
||
|
||
|
||
def _place(value: float, low: float, high: float, span: int) -> int:
|
||
"""A reading's dot along an axis of `span` dots.
|
||
|
||
A flat curve has no range to divide by and is drawn down the middle, which
|
||
is the honest picture of a metric that never moved.
|
||
"""
|
||
if high <= low:
|
||
return (span - 1) // 2
|
||
return round((value - low) / (high - low) * (span - 1))
|
||
|
||
|
||
def _cells(
|
||
lines: Sequence[Series], cols: int, rows: int
|
||
) -> tuple[dict[tuple[int, int], list[int]], tuple[float, float, float, float]]:
|
||
"""The braille cells the curves fill, and the bounds they were scaled to."""
|
||
low_x, high_x, low_y, high_y = _bounds(lines)
|
||
width, height = cols * 2, rows * 4
|
||
cells: dict[tuple[int, int], list[int]] = {}
|
||
for index, (_, points) in enumerate(lines):
|
||
previous: tuple[int, int] | None = None
|
||
for x_value, y_value in points:
|
||
spot = (
|
||
_place(x_value, low_x, high_x, width),
|
||
height - 1 - _place(y_value, low_y, high_y, height),
|
||
)
|
||
dots = _segment(*previous, *spot) if previous is not None else [spot]
|
||
for dot_x, dot_y in dots:
|
||
cell = cells.get((dot_y // 4, dot_x // 2))
|
||
bit = DOTS[dot_x % 2][dot_y % 4]
|
||
if cell is None:
|
||
cells[dot_y // 4, dot_x // 2] = [bit, index]
|
||
else:
|
||
# ponytail: the first series to reach a cell keeps its
|
||
# colour — a terminal cell has one foreground. Half-blocks
|
||
# would let two share it, at a quarter of the resolution.
|
||
cell[0] |= bit
|
||
previous = spot
|
||
return cells, (low_x, high_x, low_y, high_y)
|
||
|
||
|
||
def _row(cells: dict[tuple[int, int], list[int]], row: int, cols: int) -> Text:
|
||
"""One line of the plot, as few spans as the colours allow."""
|
||
out = Text()
|
||
run, style = "", ""
|
||
for col in range(cols):
|
||
cell = cells.get((row, col))
|
||
glyph = chr(BRAILLE + cell[0]) if cell else " "
|
||
wanted = SERIES_STYLES[cell[1] % len(SERIES_STYLES)] if cell else ""
|
||
if wanted != style:
|
||
out.append(run, style=style or None)
|
||
run, style = "", wanted
|
||
run += glyph
|
||
out.append(run, style=style or None)
|
||
return out
|
||
|
||
|
||
def plot(lines: Sequence[Series], width: int, height: int, x_label: str = "") -> Text:
|
||
"""The curves, scaled to a pane `width` by `height` cells.
|
||
|
||
Every series shares one y scale, which is the whole point of drawing them
|
||
together: two runs whose losses differ by a factor of ten should look like
|
||
it.
|
||
"""
|
||
drawn = [line for line in lines[:MAX_SERIES] if len(line[1]) > 0]
|
||
if not drawn:
|
||
return Text("No readings.", style="dim")
|
||
cols, rows = width - GUTTER, height - AXIS_ROWS
|
||
if cols < 8 or rows < 2:
|
||
return Text("Too small to draw.", style="dim")
|
||
|
||
cells, (low_x, high_x, low_y, high_y) = _cells(drawn, cols, rows)
|
||
#: Top, middle and bottom carry a value; labelling every row would be
|
||
#: noise on a chart that is mostly one curve.
|
||
labels = {0: high_y, rows // 2: (low_y + high_y) / 2, rows - 1: low_y}
|
||
|
||
out = Text(no_wrap=True, overflow="crop")
|
||
for row in range(rows):
|
||
mark = _num(labels[row]) if row in labels else ""
|
||
out.append(f"{mark:>{GUTTER - 2}} ", style="dim")
|
||
out.append("┤" if row in labels else "│", style="dim")
|
||
out.append_text(_row(cells, row, cols))
|
||
out.append("\n")
|
||
out.append(" " * (GUTTER - 1) + "└" + "─" * cols + "\n", style="dim")
|
||
left, right = _num(low_x), _num(high_x)
|
||
tail = f" {x_label}" if x_label else ""
|
||
pad = max(cols - len(left) - len(right) - len(tail), 1)
|
||
out.append(" " * GUTTER + left + " " * pad + right + tail, style="dim")
|
||
return out
|
||
|
||
|
||
def legend(labels: Iterable[str]) -> Text:
|
||
"""Which colour is which run — never colour alone."""
|
||
out = Text()
|
||
for index, label in enumerate(list(labels)[:MAX_SERIES]):
|
||
if index:
|
||
out.append(" ")
|
||
out.append("■ ", style=SERIES_STYLES[index % len(SERIES_STYLES)])
|
||
out.append(shorten(label))
|
||
return out
|
||
|
||
|
||
class Curves(Widget):
|
||
"""The plot, redrawn for whatever size the terminal gives it."""
|
||
|
||
DEFAULT_CSS = "Curves { height: 1fr; }"
|
||
|
||
def __init__(self, **kwargs: Any) -> None:
|
||
super().__init__(**kwargs)
|
||
self.lines: list[Series] = []
|
||
self.x_label = ""
|
||
|
||
def show(self, lines: Sequence[Series], x_label: str = "") -> None:
|
||
self.lines = list(lines)[:MAX_SERIES]
|
||
self.x_label = x_label
|
||
self.refresh()
|
||
|
||
def render(self) -> Text:
|
||
return plot(self.lines, self.size.width, self.size.height, self.x_label)
|