A node's numbers leave through its ports, not a logging call

The first cut had node code call fluksio.log_metric, which was a second,
undeclared way for data to leave a node: invisible to validation, absent from
the canvas, and stored where the graph could not see it. That is precisely the
MLflow discrepancy this framework exists to avoid, so it is gone.

A node that produces values over time is a generator. Every yield is a dict
keyed by output port, published the instant it happens — same port, same type
check, same place on the canvas as any other value — and what it returns is
its result. A port doing this declares stream: true, and a run keeps every
number one takes, so experiment tracking is a consequence of the graph rather
than an API beside it: a chart binds to a training curve the way it binds to a
temperature. fluksio.emit writes the same ports imperatively, for where a
yield cannot reach — inside a training framework's callback.

In a live flow an emission also wakes what is downstream, as a subscriber
publishing does; in a run it does not, because a run's graph is scheduled once
and mid-node cascades would leave 'finished' with nothing to mean. The
enqueued item carries no payload: the value is already in state, and one
carrying it would re-apply an old emission after the node returned.

Verified on the stack: 30 loss values arrived live on the flow socket during a
run, attributed to the node that produced them, and the same node run on the
remote worker streamed its curve back across the socket.

Also caches remote compile results per worker, so attaching a GPU box does not
put a network round trip in every rebuild.

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 20:53:49 +02:00
co-authored by Claude Fable 5
parent a4dae864e5
commit e18f1f6c5f
14 changed files with 528 additions and 147 deletions
+14 -1
View File
@@ -1264,6 +1264,11 @@ export const MessageSpecSchema = {
type: 'boolean',
title: 'Trigger',
default: true
},
stream: {
type: 'boolean',
title: 'Stream',
default: false
}
},
type: 'object',
@@ -1286,7 +1291,15 @@ export const MessageSpecSchema = {
:param trigger: Whether arriving values wake the node. An input with this
off is read when the node runs for some other reason, but never causes
a run and never makes the node wait — which is how a node reads a
message it also produces without depending on itself.`
message it also produces without depending on itself.
:param stream: On an output, that this port produces repeatedly *during* one
execution rather than once at the end — a training loss, a progress
fraction. A node emits on it by being a generator and yielding, or by
calling \`\`fluksio.emit\`\`. What it means downstream is nothing special:
a value published mid-execution is a value like any other. What it
means to a run is that the whole series is kept, which is how a run's
metrics are simply its streaming outputs rather than something logged
beside them.`
} as const;
export const MessagesPublicSchema = {
+9
View File
@@ -484,6 +484,14 @@ export type MessagePoints = {
* off is read when the node runs for some other reason, but never causes
* a run and never makes the node wait — which is how a node reads a
* message it also produces without depending on itself.
* :param stream: On an output, that this port produces repeatedly *during* one
* execution rather than once at the end — a training loss, a progress
* fraction. A node emits on it by being a generator and yielding, or by
* calling ``fluksio.emit``. What it means downstream is nothing special:
* a value published mid-execution is a value like any other. What it
* means to a run is that the whole series is kept, which is how a run's
* metrics are simply its streaming outputs rather than something logged
* beside them.
*/
export type MessageSpec = {
name?: string;
@@ -492,6 +500,7 @@ export type MessageSpec = {
item?: (DType | null);
interval?: number;
trigger?: boolean;
stream?: boolean;
};
export type MessagesPublic = {
+24 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Maximize2, Minimize2, X } from "lucide-react"
import { Activity, Maximize2, Minimize2, X } from "lucide-react"
import {
type ComponentProps,
lazy,
@@ -216,6 +216,7 @@ function PortList({
suggestions,
onChange,
onRenamed,
streamable = false,
}: {
title: string
specs: MessageSpec[]
@@ -224,6 +225,8 @@ function PortList({
suggestions: string[]
onChange: (next: MessageSpec[]) => void
onRenamed?: (previous: string, next: string) => void
/** Outputs only: a port a node publishes on repeatedly while it runs. */
streamable?: boolean
}) {
// The port just added, so its name field can take focus.
const [freshIndex, setFreshIndex] = useState<number | null>(null)
@@ -325,6 +328,25 @@ function PortList({
update(index, { interval: Number(event.target.value) || 0 })
}
/>
{streamable ? (
<Button
variant={spec.stream ? "secondary" : "ghost"}
size="icon-sm"
aria-label="Streaming output"
aria-pressed={spec.stream ?? false}
title={
"Published repeatedly while the node runs — a curve rather " +
"than a result. A run keeps every value it takes."
}
className={cn(
"shrink-0",
!spec.stream && "text-muted-foreground",
)}
onClick={() => update(index, { stream: !spec.stream })}
>
<Activity />
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
@@ -961,6 +983,7 @@ function PanelBody({
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => editNode({ ...node, provides })}
streamable
// Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed.
onRenamed={onRenameMessage}