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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user