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
336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""What a run is made of: isolation, the per-node record, and what it reports.
|
|
|
|
The service itself needs a database, so what is checked here is the part that
|
|
decides whether a run is correct — that two runs of one flow cannot see each
|
|
other's messages, that every node executed is reported once, and that the
|
|
parameters a caller sends are refused before anything runs if they are wrong.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from app.flow.messages import DType, MessageSpec
|
|
from app.flow.nodes import Node
|
|
from app.flow.pipeline import NodeOutcome, Pipeline
|
|
from app.flow.runs import (
|
|
MetricSink,
|
|
RunRejected,
|
|
batch_issues,
|
|
collect_result,
|
|
digest_of,
|
|
required_labels,
|
|
seed_values,
|
|
)
|
|
from app.flow.schemas import FlowDef, FlowInput, NodeDef
|
|
from app.flow.state import MemoryState
|
|
|
|
|
|
def spec(name: str, dtype: DType = DType.FLOAT, **kwargs) -> MessageSpec:
|
|
return MessageSpec(name=name, dtype=dtype, **kwargs)
|
|
|
|
|
|
def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node:
|
|
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
|
|
node.assign_flow(flow, node_id)
|
|
return node
|
|
|
|
|
|
def double_flow() -> FlowDef:
|
|
"""A flow with one input, one node and one declared output."""
|
|
return FlowDef(
|
|
name="study",
|
|
mode="batch",
|
|
inputs=[FlowInput(spec=spec("lr"), initial=0.1)],
|
|
outputs=["loss"],
|
|
nodes=[
|
|
NodeDef(
|
|
id="train",
|
|
requires=[spec("lr")],
|
|
provides=[spec("loss")],
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def build(flow: FlowDef, state: MemoryState, observer=None) -> Pipeline:
|
|
"""The pipeline a run drives, without the controller that normally builds it."""
|
|
node = make_node(
|
|
"train",
|
|
flow.name,
|
|
lambda lr, params: {"loss": lr * 2},
|
|
requires=[spec("lr")],
|
|
provides=[spec("loss")],
|
|
)
|
|
return Pipeline(nodes=[node], state=state, observer=observer)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Isolation — the reason a run has a state backend of its own
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_two_runs_of_one_flow_do_not_see_each_other():
|
|
flow = double_flow()
|
|
first, second = MemoryState(), MemoryState()
|
|
|
|
build(flow, first).run(seed_values(flow, {"lr": 0.5}))
|
|
build(flow, second).run(seed_values(flow, {"lr": 4.0}))
|
|
|
|
assert collect_result(flow, first) == {"loss": 1.0}
|
|
assert collect_result(flow, second) == {"loss": 8.0}
|
|
|
|
|
|
def test_result_falls_back_to_everything_the_flow_holds():
|
|
flow = double_flow()
|
|
flow.outputs = []
|
|
state = MemoryState()
|
|
|
|
build(flow, state).run(seed_values(flow, {"lr": 1.0}))
|
|
|
|
# The input is part of what the flow ended up holding; the engine's own
|
|
# bookkeeping keys are not.
|
|
assert collect_result(flow, state) == {"lr": 1.0, "loss": 2.0}
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# The record — every node a run executed, reported once
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_observer_sees_every_node_once():
|
|
flow = double_flow()
|
|
seen = []
|
|
|
|
build(flow, MemoryState(), observer=seen.append).run(seed_values(flow, {"lr": 1.0}))
|
|
|
|
assert [(o.node, o.ok) for o in seen] == [("study.train", True)]
|
|
assert seen[0].outputs == 1
|
|
|
|
|
|
def test_observer_reports_a_failing_node_with_its_error():
|
|
seen = []
|
|
|
|
def boom(params):
|
|
raise ValueError("no convergence")
|
|
|
|
node = make_node("train", "study", boom, provides=[spec("loss")])
|
|
Pipeline(nodes=[node], state=MemoryState(), observer=seen.append).run()
|
|
|
|
assert len(seen) == 1
|
|
assert not seen[0].ok
|
|
assert "no convergence" in seen[0].error
|
|
|
|
|
|
def test_a_failing_observer_does_not_take_the_node_down():
|
|
def refuse(_outcome):
|
|
raise RuntimeError("the database is gone")
|
|
|
|
node = make_node(
|
|
"train", "study", lambda params: {"loss": 1.0}, provides=[spec("loss")]
|
|
)
|
|
pipeline = Pipeline(nodes=[node], state=MemoryState(), observer=refuse)
|
|
pipeline.run()
|
|
|
|
assert pipeline.state["study.loss"] == 1.0
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Producing values before returning
|
|
#
|
|
# A number worth keeping is an output, not a log. A node that produces over
|
|
# time is a generator, and each yield is published on the port it names — so
|
|
# the run's metrics are its streaming outputs rather than something recorded
|
|
# beside them.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def training_flow() -> FlowDef:
|
|
return FlowDef(
|
|
name="study",
|
|
mode="batch",
|
|
inputs=[FlowInput(spec=spec("steps", DType.INT), initial=3)],
|
|
outputs=["final_loss"],
|
|
nodes=[
|
|
NodeDef(
|
|
id="train",
|
|
requires=[spec("steps", DType.INT)],
|
|
provides=[
|
|
spec("loss", stream=True),
|
|
spec("final_loss"),
|
|
],
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def training_node(flow: str = "study") -> Node:
|
|
def train(steps, params):
|
|
loss = 1.0
|
|
for _ in range(steps):
|
|
loss = loss / 2
|
|
yield {"loss": loss}
|
|
return {"final_loss": loss}
|
|
|
|
return make_node(
|
|
"train",
|
|
flow,
|
|
train,
|
|
requires=[spec("steps", DType.INT)],
|
|
provides=[spec("loss", stream=True), spec("final_loss")],
|
|
)
|
|
|
|
|
|
def test_each_yield_is_published_and_the_return_is_the_result():
|
|
flow = training_flow()
|
|
state = MemoryState()
|
|
seen: list[tuple[str, dict]] = []
|
|
pipeline = Pipeline(
|
|
nodes=[training_node()],
|
|
state=state,
|
|
emission_observer=lambda node, outputs: seen.append((node, outputs)),
|
|
)
|
|
|
|
pipeline.run(seed_values(flow, {"steps": 3}))
|
|
|
|
# It returned something, so every yield was a value produced on the way —
|
|
# each published on `loss` the moment it happened.
|
|
assert [outputs["study.loss"] for _node, outputs in seen] == [0.5, 0.25, 0.125]
|
|
assert all(node == "study.train" for node, _ in seen)
|
|
# The latest of them is what the message holds, as for any producer.
|
|
assert state["study.loss"] == 0.125
|
|
# And what it returned is the node's output, and so the run's result.
|
|
assert collect_result(flow, state) == {"final_loss": 0.125}
|
|
|
|
|
|
def test_without_a_return_the_last_yield_is_the_result():
|
|
def train(params):
|
|
yield {"loss": 1.0}
|
|
yield {"loss": 0.5}
|
|
|
|
node = make_node("train", "study", train, provides=[spec("loss", stream=True)])
|
|
seen: list[tuple[str, dict]] = []
|
|
pipeline = Pipeline(
|
|
nodes=[node],
|
|
state=MemoryState(),
|
|
emission_observer=lambda n, o: seen.append((n, o)),
|
|
)
|
|
pipeline.run()
|
|
|
|
# The last yield is the node's output rather than an emission, so it is
|
|
# not counted twice.
|
|
assert [outputs["study.loss"] for _node, outputs in seen] == [1.0]
|
|
assert pipeline.state["study.loss"] == 0.5
|
|
|
|
|
|
def test_emissions_are_checked_against_the_port_they_name():
|
|
def wrong(params):
|
|
yield {"loss": "not a number"}
|
|
return {"final_loss": 1.0}
|
|
|
|
node = make_node(
|
|
"train",
|
|
"study",
|
|
wrong,
|
|
provides=[spec("loss", stream=True), spec("final_loss")],
|
|
)
|
|
seen: list[NodeOutcome] = []
|
|
Pipeline(nodes=[node], state=MemoryState(), observer=seen.append).run()
|
|
|
|
# A wrong type is a failed node, exactly as it is for a return value —
|
|
# which is the point of emissions going out through declared ports.
|
|
assert not seen[0].ok
|
|
assert "loss" in seen[0].error
|
|
|
|
|
|
def test_an_emission_that_nothing_declares_is_ignored():
|
|
def stray(params):
|
|
yield {"undeclared": 1.0}
|
|
return {"final_loss": 2.0}
|
|
|
|
node = make_node("train", "study", stray, provides=[spec("final_loss")])
|
|
state = MemoryState()
|
|
Pipeline(nodes=[node], state=state).run()
|
|
|
|
assert "study.undeclared" not in state
|
|
assert state["study.final_loss"] == 2.0
|
|
|
|
|
|
def test_emissions_reach_the_run_as_a_series_with_a_step_each():
|
|
sink = MetricSink("run-1", batch=1)
|
|
sink.handle("study.train", {"study.loss": 1.0, "study.tag": "ignored"})
|
|
sink.handle("study.train", {"study.loss": 0.5})
|
|
|
|
# Numbers become the run's series; anything else is on the run some other
|
|
# way — as its result, or as an artifact.
|
|
assert sink._steps == {"study.loss": 1}
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# What a caller may ask for
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_parameters_must_be_declared_inputs():
|
|
flow = double_flow()
|
|
with pytest.raises(RunRejected, match="not an input"):
|
|
seed_values(flow, {"learning_rate": 0.1})
|
|
|
|
|
|
def test_parameters_are_type_checked_before_anything_runs():
|
|
flow = double_flow()
|
|
with pytest.raises(RunRejected, match="lr"):
|
|
seed_values(flow, {"lr": "fast"})
|
|
|
|
|
|
def test_a_rate_limited_port_cannot_be_run_as_a_batch():
|
|
flow = double_flow()
|
|
flow.nodes[0].provides = [spec("loss", interval=30)]
|
|
# Without a queue there is no timer to release what an interval holds, so
|
|
# the value would be dropped rather than delayed.
|
|
assert batch_issues(flow)
|
|
assert not batch_issues(double_flow())
|
|
|
|
|
|
def test_a_streaming_port_may_thin_itself_out():
|
|
flow = double_flow()
|
|
flow.nodes[0].provides = [spec("loss", interval=0.5, stream=True)]
|
|
# On a curve, an interval is asking for the canvas not to be flooded —
|
|
# the run's history still keeps every value.
|
|
assert not batch_issues(flow)
|
|
|
|
|
|
def test_the_digest_identifies_the_inputs_not_their_order():
|
|
assert digest_of({"a": 1, "b": 2}, 3) == digest_of({"b": 2, "a": 1}, 3)
|
|
assert digest_of({"a": 1}, 3) != digest_of({"a": 1}, 4)
|
|
|
|
|
|
def test_labels_come_from_the_nodes_that_ask_for_a_device():
|
|
flow = double_flow()
|
|
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}
|