An example to evaluate: a training run, its dashboard, and two bugs it found

make seed-demo builds demo_training — prepare on the engine, a GPU-bound
train, evaluate back here — and a panel that draws the loss curve while the
training is still going. It is the session's whole argument in one flow: batch
runs with parameters and a result, a generator yielding on a declared port
rather than logging, fluksio.emit from inside a callback, artifacts carrying
the dataset and the weights between machines, and a sweep whose configs are
isolated from each other. The train node prefers its label rather than
requiring it, so it runs before a GPU box exists and says which machine and
which numeric backend it actually used.

Building it turned up two real bugs. A run waited for a worker its flow only
*preferred*, because required_labels ignored device_policy — so the example
hung on a label it did not need. And a run's seed never reached the flow, so
sweeping over seeds ran the same experiment N times; it now fills an input of
that name when the flow declares one, which is what the field looked like it
did all along.

Pressing Run on a batch flow now submits a run rather than taking the old
non-durable path — that button is the first thing anyone evaluating will press,
and it was quietly doing something else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
This commit is contained in:
2026-08-18 22:04:14 +02:00
co-authored by Claude Fable 5
parent e18f1f6c5f
commit 52c0c5e2ce
7 changed files with 546 additions and 8 deletions
+26 -1
View File
@@ -8,6 +8,7 @@ from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
WebSocket,
WebSocketDisconnect,
)
@@ -27,6 +28,7 @@ from app.flow.dashboards import DashboardStore
from app.flow.events import event_bus
from app.flow.messages import qualify
from app.flow.pipeline import ValidationIssue
from app.flow.runs import RunRejected
from app.flow.schemas import (
NAME_PATTERN,
BrainGraph,
@@ -616,15 +618,38 @@ def validate_flow(name: str, controller: FlowControllerDep) -> Any:
async def run_flow(
name: str,
body: RunRequest,
request: Request,
controller: FlowControllerDep,
user: CurrentUser,
) -> Any:
"""Run every node of a flow once.
With unpublished changes this runs the draft, so the button matches what is
on the canvas. Nothing is deployed by running it.
A *batch* flow is submitted as a run instead, because that is what running
one means: it is what keeps the parameters, the series and the result, and
a button that quietly did something else would be a trap.
"""
_read_flow(controller, name)
definition = _read_flow(controller, name)
_require_enabled(controller, name)
if definition.mode == "batch":
service = getattr(request.app.state, "run_service", None)
if service is None:
raise HTTPException(status_code=503, detail="Runs are not available")
try:
await run_in_threadpool(
service.submit,
name,
params=body.inputs,
cause="api",
actor=user.email,
draft=controller.store.has_draft(name),
)
except RunRejected as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _flow_state(controller, name)
inputs = {qualify(name, key): value for key, value in body.inputs.items()}
if controller.store.has_draft(name):
await run_in_threadpool(controller.run_preview, name, inputs)
+26 -6
View File
@@ -124,15 +124,35 @@ def batch_issues(flow: FlowDef) -> list[str]:
def required_labels(flow: FlowDef) -> list[str]:
"""Worker labels this flow's nodes ask for."""
"""Worker labels this flow cannot run without.
Only the nodes that *require* their device. One that merely prefers it runs
on the engine when no such worker is attached, so holding the whole run
back to wait for one would be waiting for something it does not need.
"""
return sorted(
{node.device for node in flow.nodes if node.device and node.device.strip()}
{
node.device
for node in flow.nodes
if node.device and node.device.strip() and node.device_policy == "require"
}
)
def seed_values(flow: FlowDef, params: dict[str, Any]) -> dict[str, Any]:
"""Turn a run's parameters into the messages the flow starts from."""
def seed_values(
flow: FlowDef, params: dict[str, Any], seed: int | None = None
) -> dict[str, Any]:
"""Turn a run's parameters into the messages the flow starts from.
A run's ``seed`` fills an input of that name when the flow declares one, so
the field that distinguishes two runs of one configuration is the same
number the flow draws from — otherwise it would only look like it was, and
a sweep over seeds would run the same experiment N times. An explicit
parameter still wins, and a flow that declares no ``seed`` ignores it.
"""
specs = {declared.spec.name: declared.spec for declared in flow.inputs}
if seed is not None and "seed" in specs and "seed" not in params:
params = {**params, "seed": seed}
values: dict[str, Any] = {}
for key, value in params.items():
spec = specs.get(key)
@@ -335,7 +355,7 @@ class RunService:
params = params or {}
# Checked here rather than in the driver: a caller who mistyped a
# parameter should be told now, not by a run that fails in a minute.
seed_values(flow, params)
seed_values(flow, params, seed)
run = Run(
id=new_run_id(),
@@ -580,7 +600,7 @@ class RunService:
if run_id in self._cancelled:
pipeline.pause(flow.name)
self._publish(run, "run_started")
pipeline.run(seed_values(flow, run.params))
pipeline.run(seed_values(flow, run.params, run.seed))
result = collect_result(flow, state)
with self._lock:
cancelled = run_id in self._cancelled
+25
View File
@@ -308,3 +308,28 @@ def test_labels_come_from_the_nodes_that_ask_for_a_device():
assert required_labels(flow) == []
flow.nodes[0].device = "gpu"
assert required_labels(flow) == ["gpu"]
def test_a_preferred_device_does_not_hold_a_run_back():
flow = double_flow()
flow.nodes[0].device = "gpu"
flow.nodes[0].device_policy = "prefer"
# It runs on the engine when no such worker is attached, so waiting for
# one would be waiting for something the run does not need.
assert required_labels(flow) == []
def test_a_runs_seed_fills_an_input_of_that_name():
flow = double_flow()
flow.inputs.append(FlowInput(spec=spec("seed", DType.INT), initial=0))
# Otherwise the field that tells two runs of one configuration apart would
# only look like the number the flow draws from.
assert seed_values(flow, {}, seed=7)["study.seed"] == 7
# An explicit parameter still wins.
assert seed_values(flow, {"seed": 3}, seed=7)["study.seed"] == 3
def test_a_flow_without_a_seed_input_ignores_the_runs_seed():
flow = double_flow()
assert seed_values(flow, {"lr": 1.0}, seed=7) == {"study.lr": 1.0}