wren node: Train in the panel, as a run of wren's training flow

POST/GET /flows/{name}/nodes/{id}/train submit wren_train on the node's
targets, examples and tests and read its latest run (run.for_node,
cause "train"); "Use this model" writes `model` into the draft.
This commit is contained in:
2026-10-25 01:18:54 +02:00
parent 07fa5a2dc2
commit e9941b770d
11 changed files with 671 additions and 3 deletions
@@ -0,0 +1,33 @@
"""run.for_node
A wren node's Train button submits wren's training flow as a run. The node
it was pressed on is what ties that run back to the node's panel.
Revision ID: a3c7e5d91f42
Revises: ccb21eacf5d5
Create Date: 2026-10-24
"""
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic.
revision = "a3c7e5d91f42"
down_revision = "ccb21eacf5d5"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"run",
sa.Column(
"for_node", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True
),
)
def downgrade():
op.drop_column("run", "for_node")
+125 -1
View File
@@ -57,6 +57,7 @@ from fluksio.flow.devices import (
)
from fluksio.flow.events import event_bus
from fluksio.flow.messages import MEDIA_FAMILIES, DType, flow_of, qualify
from fluksio.flow.nodes.wren import TRAIN_FLOW, WrenNode, pack_meta
from fluksio.flow.pipeline import ValidationIssue
from fluksio.flow.runs import RunRejected
from fluksio.flow.schemas import (
@@ -84,7 +85,7 @@ from fluksio.flow.store import (
LibNotFound,
StaleVersion,
)
from fluksio.models import Flavor, Message, Run
from fluksio.models import Flavor, Message, Run, RunArtifact, RunNode
logger = logging.getLogger(__name__)
@@ -159,6 +160,24 @@ class TriggerRequest(BaseModel):
values: dict[str, Any] = {}
class Training(BaseModel):
"""A wren node's latest training run, as its panel shows it."""
id: str
#: queued, running, ok, error, cancelled or abandoned.
status: str
#: What went wrong, when it failed.
error: str = ""
#: final_loss, n_params, exact_match, threshold and coverage, once it ended.
result: dict[str, Any] = {}
#: The trained model's digest, for the node's ``model`` setting.
model: str = ""
#: From the model's own record: the confidence it recommends, and its
#: score on the node's ``tests`` when there were any.
threshold: float | None = None
test_exact_match: float | None = None
def _endpoints(controller: FlowController, definition: FlowDef) -> list[Endpoint]:
"""Everything wired into this flow from outside it."""
flow = definition.name
@@ -1010,6 +1029,111 @@ def acknowledge_node_error(
return Message(message=f"Cleared the failure on '{node_id}'")
# -----------------------------------------------------------------------------
# Training a wren node
# -----------------------------------------------------------------------------
def _node_def(controller: FlowController, name: str, node_id: str) -> NodeDef:
"""A node as the editor shows it: from the draft when there is one."""
node = next(
(n for n in _read_flow(controller, name).nodes if n.id == node_id), None
)
if node is None:
raise HTTPException(
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
)
return node
@router.post("/{name}/nodes/{node_id}/train", response_model=Training, status_code=202)
async def train_node(
name: str,
node_id: str,
request: Request,
controller: FlowControllerDep,
user: CurrentUser,
) -> Any:
"""Train a new model for a wren node, as a run of wren's training flow.
It learns the node's targets, its ``examples`` and its ``tests``, as the
editor shows them. The node keeps running its model until the new one is
set as its ``model``.
"""
node = _node_def(controller, name, node_id)
if node.type != "wren":
raise HTTPException(status_code=400, detail=f"'{node_id}' is not a wren node")
try:
wren = WrenNode(
requires=[spec for spec in node.requires if spec.name],
provides=[spec for spec in node.provides if spec.name],
params=node.params,
)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
service = getattr(request.app.state, "run_service", None)
if service is None:
raise HTTPException(status_code=503, detail="Runs are not available")
try:
run = await run_in_threadpool(
service.submit,
TRAIN_FLOW,
params={"installation": orjson.dumps(wren.installation()).decode()},
cause="train",
actor=user.email,
no_cache=True,
for_node=f"{name}.{node_id}",
)
except FlowNotFound:
raise HTTPException(
status_code=409,
detail=(
f"wren's training flow '{TRAIN_FLOW}' is not registered. Run "
"`fluksio sync wren.flow` from an environment with wren[train]."
),
) from None
except RunRejected as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return Training(id=run.id, status=run.status)
@router.get("/{name}/nodes/{node_id}/train", response_model=Training | None)
def read_training(
name: str,
node_id: str,
request: Request,
controller: FlowControllerDep,
session: SessionDep,
) -> Any:
"""A wren node's latest training run, or null when it has none."""
_node_def(controller, name, node_id)
run = session.exec(
select(Run)
.where(col(Run.flow) == TRAIN_FLOW, col(Run.for_node) == f"{name}.{node_id}")
.order_by(col(Run.created_at).desc())
).first()
if run is None:
return None
training = Training(id=run.id, status=run.status, result=run.result)
if run.status == "error":
failed = session.exec(
select(RunNode.error).where(
col(RunNode.run_id) == run.id, col(RunNode.error) != ""
)
).first()
training.error = failed or run.status_reason
# The model, once the run made one the store still has.
pack = session.get(RunArtifact, {"run_id": run.id, "name": f"{TRAIN_FLOW}.pack"})
store = getattr(request.app.state, "artifact_store", None)
path = store.path(pack.digest) if pack is not None and store else None
if pack is not None and path is not None:
meta = pack_meta(path)
training.model = pack.digest
training.threshold = meta.get("threshold")
training.test_exact_match = meta.get("test_exact_match")
return training
@router.get("/{name}/state", response_model=FlowStatePublic)
def read_flow_state(name: str, controller: FlowControllerDep, hidden: HiddenDep) -> Any:
"""The last value seen on every message of this flow."""
+29
View File
@@ -46,6 +46,13 @@ def _wren() -> Any:
return wren
def pack_meta(path: Any) -> dict[str, Any]:
"""What a model records of its training: its targets, threshold and,
when it had tests, ``test_exact_match``."""
found: dict[str, Any] = _wren().load_pack(path)["meta"]
return found
#: The input the command arrives on, and the only one that wakes the node.
PROMPT = "prompt"
#: The output the calls are reported on, and the two that say what came of
@@ -55,6 +62,8 @@ REPLY = "reply"
UNHANDLED = "unhandled"
#: The reply when nothing was done and nothing reads ``unhandled``.
CANT = "Sorry, I can't do that yet."
#: wren's training flow, which wren registers: ``fluksio sync wren.flow``.
TRAIN_FLOW = "wren_train"
_TARGET_TYPES = {DType.BOOL, DType.INT, DType.FLOAT, DType.STR}
@@ -260,6 +269,17 @@ class WrenNode(Node):
description="Your own words, and words heard wrong, in the model's: "
'{"traverse": "living room light", "page too": "patio"}.',
)
examples: list[dict[str, Any]] = Field(
default_factory=list,
description="Training only: commands to learn, targets named by port, "
'as {"state": {"user_location": "kitchen"}, "command": "lights on", '
'"calls": [["kitchen_light", true]]}.',
)
tests: list[dict[str, Any]] = Field(
default_factory=list,
description="Training only: commands a new model is scored on, like "
'examples. "calls": [] is one it must pass on.',
)
__slots__ = ("cfg", "targets", "pack", "untrained")
@@ -339,6 +359,15 @@ class WrenNode(Node):
name=name or "wren",
)
def installation(self) -> dict[str, Any]:
"""What wren's training flow learns from: the targets, each with a
value, and the ``examples`` and ``tests``."""
return {
"targets": [t | {"value": _unset(t)} for t in self.targets.values()],
"examples": self.cfg.examples,
"tests": self.cfg.tests,
}
async def start(self, app: FastAPI | None = None) -> None:
store = getattr(app.state, "artifact_store", None) if app else None
self.pack = self._load(store)
+3
View File
@@ -951,6 +951,7 @@ class RunService:
idempotency_key: str | None = None,
parent_id: str | None = None,
passed: list[str] | None = None,
for_node: str | None = None,
) -> Run:
"""Journal a run and wake an engine up for it. Never blocks on it.
@@ -993,6 +994,7 @@ class RunService:
actor=actor,
idempotency_key=idempotency_key,
parent_id=parent_id,
for_node=for_node,
)
with Session(db_engine) as session:
session.add(run)
@@ -1249,6 +1251,7 @@ class RunService:
# What its parent's caller chose. A parent from before that was
# recorded falls back to the names its params carry.
passed=source.passed,
for_node=source.for_node,
)
def _flow_of(self, run_id: str) -> str | None:
+4 -1
View File
@@ -388,8 +388,11 @@ class Run(SQLModel, table=True):
external_id: str | None = Field(
default=None, unique=True, index=True, max_length=255
)
#: Where it was asked for: api, cli, sdk, hook, sweep or import.
#: Where it was asked for: api, cli, sdk, hook, sweep, import or train.
cause: str = Field(default="api", max_length=32)
#: The node it was asked for from, as `flow.node`: the wren node a
#: training run is for. Null for every other run.
for_node: str | None = Field(default=None, max_length=255)
#: Re-execute every node, whatever the stage cache holds for it.
no_cache: bool = False
#: Run the unpublished working copy rather than what is published. Read
+186
View File
@@ -0,0 +1,186 @@
"""Train on a wren node: its wiring and settings go to wren's training flow.
The model package is optional, so ``FakeWren`` (tests/flow/conftest.py)
stands in for it.
"""
import json
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session
from fluksio.core.config import settings
from fluksio.core.db import engine as db_engine
from fluksio.models import Run, RunArtifact
from tests.flow.conftest import FakeWren
FLOW = "wren_house"
PREFIX = f"{settings.API_V1_STR}/flows/{FLOW}/nodes"
EXAMPLES = [{"command": "warmer please", "calls": [["living_room_heating", 22]]}]
TESTS = [
{"command": "is it cold outside", "calls": [["outdoor_temperature"]]},
{"command": "good night", "calls": []},
]
@pytest.fixture(autouse=True)
def fake_wren(monkeypatch: pytest.MonkeyPatch) -> FakeWren:
fake = FakeWren()
monkeypatch.setattr("fluksio.flow.nodes.wren._wren", lambda: fake)
return fake
@pytest.fixture(scope="module", autouse=True)
def house(
client: TestClient, superuser_token_headers: dict[str, str]
) -> Generator[None, None, None]:
"""Two wren nodes, so a run for one is not the other's, and a function."""
prompt = {"name": "command", "dtype": "str", "port": "prompt"}
nodes = [
{
"id": "control",
"type": "wren",
"params": {
"targets": {
"living_room_heating": {"range": [5, 30], "unit": "°C"},
"outdoor_temperature": {"description": "temperature outside"},
},
"examples": EXAMPLES,
"tests": TESTS,
},
"requires": [
prompt,
{"name": "outdoor", "dtype": "float", "port": "outdoor_temperature"},
],
"provides": [
{"name": "setpoint", "dtype": "float", "port": "living_room_heating"}
],
},
{
"id": "lights",
"type": "wren",
"requires": [prompt],
"provides": [{"name": "kitchen_light", "dtype": "bool"}],
},
{"id": "sensor", "type": "python"},
]
url = f"{settings.API_V1_STR}/flows/{FLOW}"
saved = client.put(
url, headers=superuser_token_headers, json={"name": FLOW, "nodes": nodes}
)
assert saved.status_code == 200, saved.text
yield
client.delete(url, headers=superuser_token_headers)
def test_train_submits_the_node_as_wren_s_installation(
client, superuser_token_headers, monkeypatch
):
"""The draft's ports, targets, examples and tests, never from the cache."""
seen: dict[str, object] = {}
class Recorder:
def submit(self, name, **kwargs):
seen.update(kwargs, flow=name)
return Run(id="train-1", flow=name, created_at=datetime.now(UTC))
monkeypatch.setattr(client.app.state, "run_service", Recorder())
answer = client.post(f"{PREFIX}/control/train", headers=superuser_token_headers)
assert answer.status_code == 202, answer.text
assert answer.json()["id"] == "train-1"
assert (seen["flow"], seen["no_cache"], seen["cause"], seen["for_node"]) == (
"wren_train",
True,
"train",
f"{FLOW}.control",
)
installation = json.loads(seen["params"]["installation"]) # type: ignore[index]
assert installation["targets"] == [
{
"name": "living_room_heating",
"type": "float",
"values": None,
"range": [5, 30],
"description": "",
"unit": "°C",
"readonly": False,
"value": 5.0,
},
{
"name": "outdoor_temperature",
"type": "float",
"values": None,
"range": None,
"description": "temperature outside",
"unit": None,
"readonly": True,
"value": 0.0,
},
]
assert (installation["examples"], installation["tests"]) == (EXAMPLES, TESTS)
def test_train_says_how_to_register_a_missing_training_flow(
client, superuser_token_headers
):
answer = client.post(f"{PREFIX}/control/train", headers=superuser_token_headers)
assert answer.status_code == 409
assert "fluksio sync wren.flow" in answer.json()["detail"]
def test_only_a_wren_node_trains(client, superuser_token_headers):
answer = client.post(f"{PREFIX}/sensor/train", headers=superuser_token_headers)
assert answer.status_code == 400
missing = client.post(f"{PREFIX}/nobody/train", headers=superuser_token_headers)
assert missing.status_code == 404
def test_a_node_reads_its_own_latest_training(
client, superuser_token_headers, fake_wren
):
"""With the model's digest and what its record says of it."""
pack = client.app.state.artifact_store.put([b"weights"], name="wren.bin")
fake_wren.pack = {"meta": {"threshold": 0.93, "test_exact_match": 0.5}}
now = datetime.now(UTC)
with Session(db_engine) as session:
for run_id, node, ago in (
("train-old", "control", 3),
("train-new", "control", 2),
("train-lights", "lights", 1),
):
session.add(
Run(
id=run_id,
flow="wren_train",
status="ok",
for_node=f"{FLOW}.{node}",
result={"exact_match": 0.98, "coverage": 0.9},
created_at=now - timedelta(minutes=ago),
)
)
session.add(
RunArtifact(
run_id="train-new", name="wren_train.pack", digest=pack["digest"]
)
)
session.commit()
training = client.get(f"{PREFIX}/control/train", headers=superuser_token_headers)
assert training.status_code == 200
assert training.json() == {
"id": "train-new",
"status": "ok",
"error": "",
"result": {"exact_match": 0.98, "coverage": 0.9},
"model": pack["digest"],
"threshold": 0.93,
"test_exact_match": 0.5,
}
lights = client.get(f"{PREFIX}/lights/train", headers=superuser_token_headers)
assert lights.json()["id"] == "train-lights"