Export runs and their curves as tables an analysis reads
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m11s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m17s
pre-commit / pre-commit (push) Failing after 2m44s
Test Backend / test-backend (push) Successful in 3m0s
Compose Smoke Test / test-compose (push) Successful in 41s
Playwright Tests / merge-reports (push) Successful in 8m14s
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m11s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m17s
pre-commit / pre-commit (push) Failing after 2m44s
Test Backend / test-backend (push) Successful in 3m0s
Compose Smoke Test / test-compose (push) Successful in 41s
Playwright Tests / merge-reports (push) Successful in 8m14s
`fluksio export metrics` is the long table — a row per run, metric and step —
and `fluksio export runs` the wide one, a row per run with the inputs that
*vary* across the selection as columns beside its final numbers, status,
duration and the commit and digest of the code it ran. Both carry the run id
on every row, which is the join back to the run page and what makes an
exported file auditable. `Client.export_metrics`/`export_runs` answer the same
rows to a notebook.
The engine streams csv or jsonl from two routes declared above `/{run_id}`;
parquet is a client-side conversion behind the new `fluksio[parquet]` extra,
so nobody pays for pyarrow who does not want dtypes kept. The long export
reads each run through `_series`, so a cached node's curve comes with it, and
`--stride` thins each series rather than the concatenation of all of them.
Two things they needed on the way: `GET /runs` takes `?since=` and `?before=`,
so a long history pages by the last row's own timestamp instead of an offset
that shifts under it; and a read that reaches no engine now says so in half a
second rather than seven, because `runs`, `flavors`, `export` and an unwatched
`status` pass `retries=0`. Everything that submits keeps them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
This commit is contained in:
@@ -642,3 +642,146 @@ def test_a_tree_that_did_not_move_is_not_written_again():
|
||||
run = session.get(Run, "stamp-2")
|
||||
|
||||
assert service._restamp(run, "same") == "same"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Export
|
||||
#
|
||||
# Two tables an analysis reads: the long one a curve is plotted from, and the
|
||||
# wide one arms are compared in. Both carry the run id on every row.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exported():
|
||||
"""Two runs of one flow, each with two curves and one input that varies."""
|
||||
made = datetime.now(UTC)
|
||||
with Session(db_engine) as session:
|
||||
for index, (lr, acc) in enumerate([(0.1, 0.5), (0.01, 0.25)]):
|
||||
run_id = f"exp-{index}"
|
||||
session.add(
|
||||
Run(
|
||||
id=run_id,
|
||||
flow="export-study",
|
||||
status="ok",
|
||||
params={"lr": lr, "epochs": 10},
|
||||
result={"acc": acc, "note": "n/a"},
|
||||
created_at=made + timedelta(seconds=index),
|
||||
)
|
||||
)
|
||||
for name in ("study.loss", "study.val"):
|
||||
for step in range(4):
|
||||
session.add(_metric(run_id, name, step, float(step), 100.0 + step))
|
||||
session.commit()
|
||||
yield ["exp-0", "exp-1"]
|
||||
with Session(db_engine) as session:
|
||||
for run_id in ("exp-0", "exp-1"):
|
||||
for row in session.exec(
|
||||
select(RunMetric).where(col(RunMetric.run_id) == run_id)
|
||||
).all():
|
||||
session.delete(row)
|
||||
run = session.get(Run, run_id)
|
||||
if run is not None:
|
||||
session.delete(run)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _lines(answer) -> list[dict]:
|
||||
return [json.loads(line) for line in answer.text.splitlines() if line]
|
||||
|
||||
|
||||
def test_an_export_strides_each_series_and_names_its_run(
|
||||
client, superuser_token_headers, exported
|
||||
):
|
||||
"""Every second point of every curve — not every second row of all of them."""
|
||||
answer = client.get(
|
||||
f"{settings.API_V1_STR}/runs/export/metrics",
|
||||
params={"ids": ",".join(exported), "stride": 2, "format": "jsonl"},
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
|
||||
assert answer.status_code == 200
|
||||
rows = _lines(answer)
|
||||
assert {row["run"] for row in rows} == set(exported)
|
||||
curves: dict[tuple[str, str], list[int]] = {}
|
||||
for row in rows:
|
||||
curves.setdefault((row["run"], row["name"]), []).append(row["step"])
|
||||
assert len(curves) == 4
|
||||
assert all(steps == [0, 2] for steps in curves.values())
|
||||
|
||||
|
||||
def test_an_exported_run_row_carries_the_inputs_that_vary(
|
||||
client, superuser_token_headers, exported
|
||||
):
|
||||
"""The sweep axis becomes columns; what every run shares stays out of them."""
|
||||
|
||||
def export(**extra):
|
||||
answer = client.get(
|
||||
f"{settings.API_V1_STR}/runs/export/runs",
|
||||
params={"ids": ",".join(exported), **extra},
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert answer.status_code == 200
|
||||
return answer
|
||||
|
||||
rows = _lines(export(format="jsonl"))
|
||||
assert [row["id"] for row in rows] == ["exp-1", "exp-0"]
|
||||
assert {row["param.lr"] for row in rows} == {0.1, 0.01}
|
||||
# `epochs` is the same on both runs, so it is not what they differ by; a
|
||||
# string in the result is not one of the run's numbers.
|
||||
assert "param.epochs" not in rows[0]
|
||||
assert rows[0]["metric.acc"] == 0.25
|
||||
assert "metric.note" not in rows[0]
|
||||
assert "param.epochs" in _lines(export(format="jsonl", params="epochs"))[0]
|
||||
|
||||
# csv is the default, and the columns are in the order the header names.
|
||||
header = export().text.splitlines()[0]
|
||||
assert header.startswith("id,flow,status,")
|
||||
assert header.endswith("param.lr,metric.acc")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paged():
|
||||
"""Three runs of one flow, a minute apart."""
|
||||
made = datetime.now(UTC).replace(microsecond=0)
|
||||
with Session(db_engine) as session:
|
||||
for index in range(3):
|
||||
session.add(
|
||||
Run(
|
||||
id=f"page-{index}",
|
||||
flow="paged-study",
|
||||
status="ok",
|
||||
created_at=made + timedelta(minutes=index),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
yield made
|
||||
with Session(db_engine) as session:
|
||||
for index in range(3):
|
||||
run = session.get(Run, f"page-{index}")
|
||||
if run is not None:
|
||||
session.delete(run)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_runs_page_by_when_they_were_created(client, superuser_token_headers, paged):
|
||||
"""`before` is the cursor: the last row's own timestamp reads the next page."""
|
||||
|
||||
def listed(**params):
|
||||
answer = client.get(
|
||||
f"{settings.API_V1_STR}/runs",
|
||||
params={"flow": "paged-study", **params},
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert answer.status_code == 200
|
||||
return [row["id"] for row in answer.json()]
|
||||
|
||||
assert listed() == ["page-2", "page-1", "page-0"]
|
||||
assert listed(before=(paged + timedelta(minutes=2)).isoformat()) == [
|
||||
"page-1",
|
||||
"page-0",
|
||||
]
|
||||
assert listed(since=(paged + timedelta(minutes=1)).isoformat()) == [
|
||||
"page-2",
|
||||
"page-1",
|
||||
]
|
||||
|
||||
@@ -130,3 +130,25 @@ def test_a_run_that_is_gone_stops_the_wait_at_once(monkeypatch):
|
||||
handle.wait(poll=0)
|
||||
assert caught.value.status == 404
|
||||
assert len(calls) == 1, "a 404 is an answer, not a blip"
|
||||
|
||||
|
||||
def test_an_export_is_read_as_rows(monkeypatch):
|
||||
"""jsonl on the wire: a stream of documents, not one."""
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen.update(request.url.params)
|
||||
return httpx.Response(
|
||||
200,
|
||||
text='{"run": "r1", "step": 0}\n{"run": "r1", "step": 1}\n',
|
||||
headers={"content-type": "application/x-ndjson"},
|
||||
)
|
||||
|
||||
client = a_client(handler, monkeypatch)
|
||||
rows = client.export_metrics(flow="train", ids=["r1", "r2"], status="ok")
|
||||
|
||||
assert seen["format"] == "jsonl"
|
||||
assert seen["ids"] == "r1,r2"
|
||||
assert seen["flow"] == "train"
|
||||
assert seen["status"] == "ok"
|
||||
assert [row["step"] for row in rows] == [0, 1]
|
||||
|
||||
@@ -312,3 +312,36 @@ def test_the_run_prompt_names_an_answer_of_the_wrong_type() -> None:
|
||||
with patch("builtins.input", side_effect=["fast"]):
|
||||
with pytest.raises(SyncError, match="lr"):
|
||||
_ask_params(definition)
|
||||
|
||||
|
||||
def test_an_export_is_parsed_with_its_selection() -> None:
|
||||
"""`export` is a group of two tables, and both take the same filters."""
|
||||
from fluksio.cli import _parser
|
||||
from fluksio.sdk.cli import cmd_export_metrics, cmd_export_runs
|
||||
|
||||
parser = _parser()
|
||||
metrics = parser.parse_args(
|
||||
[
|
||||
"export",
|
||||
"metrics",
|
||||
"--flow",
|
||||
"train",
|
||||
"--run",
|
||||
"a",
|
||||
"--run",
|
||||
"b",
|
||||
"--name",
|
||||
"loss,val",
|
||||
"--stride",
|
||||
"5",
|
||||
]
|
||||
)
|
||||
assert metrics.func is cmd_export_metrics
|
||||
assert metrics.run == ["a", "b"]
|
||||
assert metrics.stride == 5
|
||||
assert metrics.format == "csv"
|
||||
|
||||
runs = parser.parse_args(["export", "runs", "--params", "lr", "--format", "jsonl"])
|
||||
assert runs.func is cmd_export_runs
|
||||
assert runs.params == "lr"
|
||||
assert runs.format == "jsonl"
|
||||
|
||||
Reference in New Issue
Block a user