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