Files
stroblmeandClaude Opus 5 f370601aec
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
Draw curves in the terminal, and stop waiting five seconds to hear
`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
2026-08-30 12:51:08 +02:00

213 lines
7.7 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)