Scroll the runs table, pick a range, and choose what a comparison plots against
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Failing after 3m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m44s
pre-commit / pre-commit (push) Failing after 3m54s
Test Backend / test-backend (push) Successful in 3m12s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m28s

This commit is contained in:
2026-08-25 16:32:34 +02:00
parent 3c15964364
commit 4350916bd8
14 changed files with 498 additions and 151 deletions
+31 -6
View File
@@ -132,6 +132,9 @@ class MetricSeries(BaseModel):
class SeriesAnswer(BaseModel):
metric: str
#: What the x values are: "step", "time" (seconds since this run's first
#: reading), or the name of another metric this one was plotted against.
x: str = "step"
lines: list[MetricSeries] = Field(default_factory=list)
@@ -356,13 +359,38 @@ def read_metrics(
return rows
def _points(session: Session, run_id: str, metric: str, x: str) -> list[list[float]]:
"""One run's readings of ``metric``, against whichever x was asked for.
The step is the default because it is what every run has. Time answers
"which one got there sooner", and is measured from this run's own first
reading so that runs started hours apart still lie on top of each other.
Another metric answers "against what the loop was actually counting" — an
epoch, or samples seen — and is joined on the step the two share, which is
the only thing they have in common.
"""
rows = _series(session, run_id, metric)
if x == "time":
if not rows:
return []
start = min(row.ts for row in rows)
return [[row.ts - start, row.value] for row in rows]
if x and x != "step":
against = {row.step: row.value for row in _series(session, run_id, x)}
return [[against[row.step], row.value] for row in rows if row.step in against]
return [[float(row.step), row.value] for row in rows]
@router.get("/series/compare", response_model=SeriesAnswer)
def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
def compare_metric(session: SessionDep, ids: str, metric: str, x: str = "") -> Any:
"""One metric across several runs, as the chart widget's series shape.
This is the comparison view: it answers in the same shape a flow answers a
chart's query with, so putting three training curves beside each other is
a widget binding rather than a screen of its own.
``x`` names what to plot against — nothing or "step", "time", or another
metric of the same runs.
"""
run_ids = [part for part in ids.split(",") if part]
if not run_ids:
@@ -376,13 +404,10 @@ def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
run = runs.get(run_id)
if run is None:
continue
rows = _series(session, run_id, metric)
label = run_id
if run.seed is not None:
label = f"{run_id} (seed {run.seed})"
lines.append(
MetricSeries(
label=label, points=[[float(row.step), row.value] for row in rows]
)
MetricSeries(label=label, points=_points(session, run_id, metric, x))
)
return SeriesAnswer(metric=metric, lines=lines)
return SeriesAnswer(metric=metric, x=x or "step", lines=lines)
+83 -1
View File
@@ -8,7 +8,7 @@ import json
from datetime import UTC, datetime
import pytest
from sqlmodel import Session, select
from sqlmodel import Session, col, select
from fluksio.core.config import settings
from fluksio.core.db import engine as db_engine
@@ -380,3 +380,85 @@ def test_a_curve_whose_run_is_gone_is_empty_rather_than_an_error(
)
assert answer.status_code == 200
assert answer.json() == []
def _metric(run_id: str, name: str, step: int, value: float, ts: float) -> RunMetric:
return RunMetric(run_id=run_id, name=name, step=step, value=value, ts=ts)
@pytest.fixture
def plotted():
"""A run with a loss curve and an epoch counter beside it."""
run_id = new_run_id()
with Session(db_engine) as session:
session.add(
Run(id=run_id, flow="study", status="ok", created_at=datetime.now(UTC))
)
for step, (loss, epoch) in enumerate([(1.0, 10.0), (0.5, 20.0), (0.25, 30.0)]):
session.add(_metric(run_id, "study.loss", step, loss, 100.0 + step * 5))
session.add(_metric(run_id, "study.epoch", step, epoch, 100.0 + step * 5))
session.commit()
yield run_id
with Session(db_engine) as session:
for row in session.exec(
select(RunMetric).where(col(RunMetric.run_id) == run_id)
).all():
session.delete(row)
session.delete(session.get(Run, run_id))
session.commit()
def test_a_comparison_is_plotted_against_the_step_by_default(
client, superuser_token_headers, plotted
):
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss"},
headers=superuser_token_headers,
).json()
assert answer["x"] == "step"
assert answer["lines"][0]["points"] == [[0.0, 1.0], [1.0, 0.5], [2.0, 0.25]]
def test_time_is_measured_from_this_runs_own_first_reading(
client, superuser_token_headers, plotted
):
"""Runs started hours apart still lie on top of each other."""
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss", "x": "time"},
headers=superuser_token_headers,
).json()
assert answer["x"] == "time"
assert answer["lines"][0]["points"] == [[0.0, 1.0], [5.0, 0.5], [10.0, 0.25]]
def test_one_metric_can_be_plotted_against_another(
client, superuser_token_headers, plotted
):
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss", "x": "study.epoch"},
headers=superuser_token_headers,
).json()
assert answer["lines"][0]["points"] == [[10.0, 1.0], [20.0, 0.5], [30.0, 0.25]]
def test_a_step_the_x_metric_never_reached_is_left_out(
client, superuser_token_headers, plotted
):
"""The join is on the step, which is the only thing two series share."""
with Session(db_engine) as session:
session.add(_metric(plotted, "study.loss", 3, 0.1, 120.0))
session.commit()
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss", "x": "study.epoch"},
headers=superuser_token_headers,
).json()
assert [point[0] for point in answer["lines"][0]["points"]] == [10.0, 20.0, 30.0]