wren node: targets from the wiring, on wren 2

Every output is a target and every input besides prompt a reading. A
targets setting gives descriptions, values, ranges and per-target
guards, model takes a trained pack's artifact digest, min_confidence
defaults to the model's threshold. A command is applied whole or not at
all, and targets the model was not trained on read degraded. The home
path (area/kind ports, groups, brightness, temperature answers) is gone.
This commit is contained in:
2026-10-24 21:28:42 +02:00
parent 6892651e76
commit 07fa5a2dc2
5 changed files with 745 additions and 863 deletions
+292 -506
View File
@@ -1,18 +1,17 @@
"""Wren: a typed command in, values for the devices wired to it out.
"""Wren: a typed command in, values for the targets wired to it out.
`Wren <https://github.com/stroblme/wren>`_ is a small language model trained on
one job: read a house — its areas, the devices in each and their states — and
answer a command with calls on those devices. This node is the bridge. Its
output ports *are* the devices, named ``<area>_<kind>``, so the wiring is the
`Wren <https://github.com/stroblme/wren>`_ is a small language model that
turns a short command into calls on a list of targets. This node is the
bridge. Its ports *are* the targets: each output is one a command may set,
each input besides ``prompt`` a reading it may ask about. The wiring is the
allowlist: the model can reach nothing that is not wired to the node.
"""
from __future__ import annotations
import re
import time
from collections.abc import Iterable
from typing import Any, NamedTuple
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field
from threadpoolctl import threadpool_limits
@@ -20,6 +19,9 @@ from threadpoolctl import threadpool_limits
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes.base import Node
if TYPE_CHECKING:
from fastapi import FastAPI
def _wren() -> Any:
"""The model package, which is optional.
@@ -33,120 +35,29 @@ def _wren() -> Any:
except ImportError:
# Not `pip install wren`: that name on PyPI is another package.
raise RuntimeError(
"the wren node needs the model: pip install git+https://github.com/"
"stroblme/wren@eae4d6ff9ded68e36624972fd3f4a8f8d210b0c2"
"the wren node needs the model: pip install "
"git+https://github.com/stroblme/wren"
) from None
if not hasattr(wren, "Target"):
raise RuntimeError(
"the wren node needs wren 2, the installed one is older: pip install "
"--upgrade git+https://github.com/stroblme/wren"
)
return wren
#: The last word of a target port, and the kind of device it stands for.
KIND_WORDS = {
"light": "light",
"lamp": "light",
"brightness": "light",
"setpoint": "heat",
"heating": "heat",
"heat": "heat",
"boost": "heat",
"media": "media",
"music": "media",
"tv": "media",
"shutter": "shutter",
"blind": "shutter",
"cover": "shutter",
"water": "water",
"watering": "water",
"sprinkler": "water",
"switch": "switch",
"plug": "switch",
"socket": "switch",
"appliances": "switch",
}
#: What the node reads, and the types each may be. Only the prompt wakes it.
#: Besides these, ``<area>_temperature`` inputs answer "how warm is it".
INPUTS = {
"prompt": {DType.STR},
"presence": {DType.STR},
"outdoor": {DType.FLOAT, DType.INT},
"dark": {DType.BOOL},
}
#: 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
#: the command. Every other output is a device.
#: the command. Every other output is a target.
ACTIONS = "actions"
REPLY = "reply"
UNHANDLED = "unhandled"
#: The reply when nothing was done and nothing reads ``unhandled``.
CANT = "Sorry, I can't do that yet."
#: The output for the house's brightness, 0 to 100, which the model's
#: brightness calls write whatever area they name.
BRIGHTNESS = "brightness"
_TARGET_TYPES = {DType.BOOL, DType.INT, DType.FLOAT, DType.STR}
#: The tools that act on each kind.
_TOOLS = {
"light": "lights",
"media": "media",
"heat": "heating",
"shutter": "shutters",
"water": "watering",
"switch": "switches",
}
#: What a call sets a target to, by the target's type: (bool, number, text).
#: None is a call that type has no value for, which is then not applied.
#: `heating.set` carries its own number.
_VALUES: dict[str, tuple[bool | None, float | None, str | None]] = {
"lights.on": (True, 100, "on"),
"lights.off": (False, 0, "off"),
"media.play": (True, None, "play"),
"media.stop": (False, None, "stop"),
"heating.boost": (True, None, "boost"),
"heating.off": (False, None, "off"),
"shutters.up": (True, 100, "up"),
"shutters.down": (False, 0, "down"),
"shutters.stop": (None, None, "stop"),
"watering.start": (True, None, "on"),
"watering.stop": (False, None, "off"),
"switches.on": (True, 1, "on"),
"switches.off": (False, 0, "off"),
}
#: The brightness calls, and what "dim" and "brighter" do: a step from the
#: brightness now, or from a usual one when none is known. Dimmed lights
#: stay on.
_LEVELS = ("lights.set", "lights.dim", "lights.brighten")
_STEP, _DIMMEST, _BRIGHTEST, _USUAL = 30, 10, 100, 70
#: An applied call, said: "kitchen light on". `heating.set` says its number.
_SAID = {
"lights.on": "light on",
"lights.off": "light off",
"media.play": "media playing",
"media.stop": "media stopped",
"heating.boost": "heating boosted",
"heating.off": "heating off",
"shutters.up": "shutters up",
"shutters.down": "shutters down",
"shutters.stop": "shutters stopped",
"watering.start": "watering on",
"watering.stop": "watering off",
"switches.on": "switch on",
"switches.off": "switch off",
}
#: What every area held in training: a light, and in the living room, which
#: always existed, media and heating besides. A house missing them reads as
#: unfamiliar and the confidence drops with it, so they are filled in; a call
#: on one that nothing is wired to is reported and not applied. Every area is
#: shown, wired or not, or "the bathroom light" lands on a light that is.
_TRAINED = {"living room": ("light", "media", "heat")}
#: wren's areas outside, whose temperature is the ``outdoor`` reading.
_OUTSIDE = {"garden", "patio", "garage", "driveway", "balcony"}
#: Number words, which wren was trained on as digits.
_ONES = (
"zero one two three four five six seven eight nine ten eleven twelve "
@@ -157,154 +68,6 @@ _NUMBERS = {w: n for n, w in enumerate(_ONES)} | {
w: 20 + 10 * n for n, w in enumerate(_TENS)
}
#: A command for a whole group of devices: "turn off all lights".
_GROUP = re.compile(r"\b(all|every|everything|everywhere)\b")
#: What leaves such a command to the model and the assistant: a question, or
#: an exception to the group.
_NOT_GROUP = re.compile(
r"^(is|are|was|were|do|does|did|can|could|what|which|how|why|when|where|who)\b"
r"|\b(except|but|apart from|other than)\b"
)
#: The kind a group command names, by its word. None named is every kind.
_GROUP_KINDS = {
"light": "light",
"lights": "light",
"lamps": "light",
"music": "media",
"media": "media",
"tv": "media",
"shutters": "shutter",
"blinds": "shutter",
"heating": "heat",
"watering": "water",
"sprinklers": "water",
"switches": "switch",
"plugs": "switch",
"appliances": "switch",
}
#: The call a group command makes on each kind, by its action word.
_GROUP_TOOLS = {
"on": {
"light": "lights.on",
"media": "media.play",
"water": "watering.start",
"switch": "switches.on",
},
"off": {
"light": "lights.off",
"media": "media.stop",
"heat": "heating.off",
"water": "watering.stop",
"switch": "switches.off",
},
"open": {"shutter": "shutters.up"},
"up": {"shutter": "shutters.up"},
"raise": {"shutter": "shutters.up"},
"close": {"shutter": "shutters.down"},
"down": {"shutter": "shutters.down"},
"lower": {"shutter": "shutters.down"},
"stop": {
"media": "media.stop",
"shutter": "shutters.stop",
"water": "watering.stop",
},
}
#: A group call, said: "all lights off".
_GROUP_SAID = {
"lights.on": "lights on",
"lights.off": "lights off",
"media.play": "media playing",
"media.stop": "media stopped",
"heating.off": "heating off",
"shutters.up": "shutters up",
"shutters.down": "shutters down",
"shutters.stop": "shutters stopped",
"watering.start": "watering on",
"watering.stop": "watering off",
"switches.on": "switches on",
"switches.off": "switches off",
}
class _Call(NamedTuple):
"""A call the node makes itself, shaped as the model's."""
tool: str
args: dict[str, Any]
delay_s: float | None = None
def _device(port: str, areas: dict[str, str]) -> tuple[str, str] | None:
"""The (area, kind) a port name stands for, or None."""
for slug, area in areas.items():
word = port.removeprefix(f"{slug}_")
if word != port and word in KIND_WORDS:
return area, KIND_WORDS[word]
return None
def _on(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value > 0
return str(value).strip().lower() in {"on", "up", "open", "play", "true"}
def _state(kind: str, values: list[Any]) -> str:
"""A device's state as the model reads it, from what its targets hold."""
if kind == "heat":
temps = [
v for v in values if isinstance(v, (int, float)) and not isinstance(v, bool)
]
return f"{round(temps[0])}C" if temps else "20C"
on = bool(values) and _on(values[0])
if kind == "shutter":
return "up" if on else "down"
return "on" if on else "off"
def _value(call: Any, dtype: DType) -> Any:
"""What ``call`` sets a target of this type to, or None."""
if call.tool == "heating.set":
temp = call.args.get("temp")
flag, number, text = (
None,
temp if isinstance(temp, (int, float)) else None,
None,
)
elif call.tool == "lights.set":
level = call.args.get("level")
flag, number, text = (
None,
level if isinstance(level, (int, float)) else None,
None,
)
else:
flag, number, text = _VALUES.get(call.tool, (None, None, None))
if dtype is DType.BOOL:
return flag
if dtype is DType.STR:
return text
if number is None:
return None
return int(number) if dtype is DType.INT else float(number)
def _said(call: Any, area: str) -> str:
"""An applied call as the reply says it: "garage light on in 5 minutes"."""
if call.tool == "heating.set":
said = f"heating to {_value(call, DType.FLOAT):g} degrees"
elif call.tool == "lights.set":
said = f"brightness at {_value(call, DType.FLOAT):g} percent"
else:
said = _SAID.get(call.tool, call.tool)
if call.delay_s:
minutes = round(call.delay_s / 60)
said += f" in {minutes} minute{'s' * (minutes != 1)}"
# The brightness is the house's, not the area's the call names.
return said if call.tool == "lights.set" else f"{area} {said}"
def _plain(command: str) -> str:
"""The command as the model was trained on it: lower case, no sentence marks.
@@ -343,52 +106,125 @@ def _digits(command: str) -> str:
return re.sub(rf"\b({words})\b", lambda m: str(_NUMBERS[m[1]]), command)
def _named(areas: Iterable[str], command: str) -> list[str]:
"""The areas ``command`` names, in the house's order."""
return [a for a in areas if re.search(rf"\b{re.escape(a)}\b", command)]
def _learnt(target: dict[str, Any]) -> tuple[Any, ...]:
"""What a model learns of a target: all but its unit, which it never sees,
and a range as floats, so [5, 30] is (5.0, 30.0)."""
low_high = target.get("range")
return (
target.get("name"),
target.get("type"),
target.get("values"),
None if low_high is None else tuple(float(n) for n in low_high),
target.get("description", ""),
bool(target.get("readonly", False)),
)
def _asked(house: Any, command: str) -> set[str]:
"""The areas a call may land on: those the command names, or else the
user's and the living room.
def _unset(target: dict[str, Any]) -> Any:
"""The value of a target nothing has set yet: off, 0 within its range, or
its first value."""
if target["type"] == "bool":
return False
if target["type"] == "str":
return (target["values"] or [""])[0]
low, high = target["range"] or (0, 0)
number = min(max(0, low), high)
return int(number) if target["type"] == "int" else float(number)
That is how wren picks the areas it shows as the command's own. The few
it adds besides are there to be told apart, and a call on one of them is a
word the model never learned landing on a neighbour: "switch on the
appliances" turned on the bathroom light at 0.97.
"""
return set(_named(house.areas, command)) or {house.user_area, "living room"}
def _typed(value: Any, dtype: DType) -> Any:
"""A call's value as its port's type: wren may write 21 for a float."""
if dtype is DType.INT:
return int(value)
if dtype is DType.FLOAT:
return float(value)
return value
def _spoken(value: Any, unit: str | None) -> str:
"""A value as the reply says it: "on", "down", "21 °C"."""
if isinstance(value, bool):
return "on" if value else "off"
if isinstance(value, str):
return value.lower()
return f"{value:g}" + (f" {unit}" if unit else "")
def _said(port: str, value: Any, unit: str | None, delay_s: float | None) -> str:
"""An applied call as the reply says it: "living room heating to 21 °C in
5 minutes"."""
to = "" if isinstance(value, (bool, str)) else "to "
said = f"{port.replace('_', ' ')} {to}{_spoken(value, unit)}"
if delay_s:
minutes = delay_s >= 60
n = round(delay_s / 60 if minutes else delay_s)
said += f" in {n} {'minute' if minutes else 'second'}{'s' * (n != 1)}"
return said
def _text(call: Any) -> str:
"""The call as the chat shows it, less the device it names."""
args = ", ".join(f"{k}={v}" for k, v in call.args.items() if k != "id")
return f"{call.tool}({args})"
"""The call as wren writes it, less its delay: ``set(kitchen_light, true)``."""
if call.value is None:
return f"read({call.target})"
value = call.value
if isinstance(value, bool):
value = "true" if value else "false"
elif isinstance(value, float):
value = f"{value:g}"
return f"set({call.target}, {value})"
class TargetInfo(BaseModel):
"""What the model is told of a target besides its port, and how far it is
trusted with it."""
model_config = ConfigDict(extra="forbid")
description: str = Field(
default="",
description='More words for the target, which help pick it: "awning over the patio".',
)
values: list[str] | None = Field(
default=None,
description='The only values a str target takes: ["UP", "DOWN", "STOP"].',
)
range: tuple[float, float] | None = Field(
default=None,
description="The lowest and highest value of a number: [5, 30].",
)
unit: str | None = Field(
default=None,
description='Said after a number in the reply: "°C". The model never sees it.',
)
min_confidence: float | None = Field(
default=None,
ge=0,
le=1,
description="The node's min_confidence for this target alone, such as a "
"higher one for a lock.",
)
pass_on: bool = Field(
default=False,
description="Never set by wren: a command on it goes on to unhandled.",
)
class WrenNode(Node):
"""Turn the command on ``prompt`` into values for the devices wired to it.
"""Turn the command on ``prompt`` into values for the targets wired to it.
Inputs: ``prompt`` (the command, and the only one that wakes the node),
``presence`` (the area the user is in), ``outdoor`` (°C), ``dark``
(otherwise read off the clock: before 07:00 or from 18:00) and any
``<area>_temperature``. A question for a temperature is answered from
that reading, or from ``outdoor`` for an area outside.
Inputs: ``prompt``, the command and the only one that wakes the node, and
any number of readings, which a command may ask about but not set.
The command reaches the model with the ``aliases`` applied and its number
words as digits. A command for all of a kind ("turn off all lights",
"everything off in the office") is not the model's: the node applies it
to every wired device of that kind itself.
Outputs: ``actions``, a list of records, one per call, ``reply``, a
sentence saying what was done or what a reading holds, or that nothing
was done when no node reads ``unhandled``, ``unhandled``, the prompt when
nothing was, and any number of targets a command may set.
Outputs: ``actions``, a list of records, one per call and target,
``reply``, a sentence saying what was applied ("Kitchen light on."), or
that nothing was when no node reads ``unhandled``, ``unhandled``, the
prompt when nothing was, ``brightness``, the house's, for "dim the
lights" or "set the brightness to 30%", and any number of targets
named ``<area>_<kind>``. Targets of one area and kind are one device to
the model; each takes what the call means for its type.
A delayed call is held by the engine's work queue, like the Trigger
node's second value.
A target is named by its port. Its type, its current value and its entry
in ``targets`` are what the model is shown. The command reaches the model
with the ``aliases`` applied and its number words as digits. A delayed
call is held by the engine's work queue, like the Debounce node's second
value.
"""
class Params(BaseModel):
@@ -398,19 +234,34 @@ class WrenNode(Node):
default=False,
description="Write to the targets. Off, the node only reports what it would do.",
)
min_confidence: float = Field(
default=0.93,
model: str = Field(
default="",
pattern=r"^(sha256:[0-9a-f]{64})?$",
description=(
"The trained model, an artifact digest. Empty runs wren's demo "
"model, which knows only its demo home."
),
)
min_confidence: float | None = Field(
default=None,
ge=0,
le=1,
description="Below this, nothing is written; the calls are still reported.",
description="Below this, nothing is written; the calls are still "
"reported. Empty takes the model's own threshold.",
)
targets: dict[str, TargetInfo] = Field(
default_factory=dict,
description="By target port: description, values, range, unit, "
'min_confidence and pass_on, as {"canopy": {"values": ["UP", '
'"DOWN", "STOP"], "description": "awning over the patio"}}.',
)
aliases: dict[str, str] = Field(
default_factory=dict,
description="The house's own words, and words heard wrong, in wren's: "
description="Your own words, and words heard wrong, in the model's: "
'{"traverse": "living room light", "page too": "patio"}.',
)
__slots__ = ("cfg", "devices", "readings")
__slots__ = ("cfg", "targets", "pack", "untrained")
# A call is a command to a device; doing it twice is not doing it once.
idempotent = False
@@ -423,69 +274,64 @@ class WrenNode(Node):
name: str | None = None,
):
self.cfg = self.Params.model_validate(params or {})
wren = _wren()
_wren()
inputs = self._normalize_ports(requires)
outputs = self._normalize_ports(provides)
areas = {area.replace(" ", "_"): area for area in wren.AREAS}
#: The area each ``<area>_temperature`` input reads, to its port.
self.readings: dict[str, str] = {}
for spec in inputs:
reading = areas.get(spec.port.removesuffix("_temperature"))
if reading and spec.port.endswith("_temperature"):
self.readings[reading] = spec.port
allowed = INPUTS["outdoor"] if reading else INPUTS.get(spec.port)
if allowed is None:
raise ValueError(
f"'{spec.port}' is not an input of wren. It reads "
f"{', '.join(INPUTS)} and <area>_temperature."
)
if spec.dtype not in allowed:
raise ValueError(
f"'{spec.port}' is {spec.dtype.value}; wren reads it as "
f"{' or '.join(sorted(d.value for d in allowed))}."
)
if not any(spec.port == "prompt" for spec in inputs):
if not any(spec.port == PROMPT for spec in inputs):
raise ValueError("wren needs a 'prompt' input: the command to act on.")
#: (area, kind) to the ports standing for that device.
self.devices: dict[tuple[str, str], list[str]] = {}
ports: list[tuple[MessageSpec, bool]] = []
for spec in outputs:
if spec.port == ACTIONS:
if spec.dtype is not DType.LIST or spec.item_dtype is not DType.RECORD:
raise ValueError(f"'{ACTIONS}' is a list of records.")
continue
if spec.port in (REPLY, UNHANDLED):
elif spec.port in (REPLY, UNHANDLED):
if spec.dtype is not DType.STR:
raise ValueError(f"'{spec.port}' is a str.")
continue
if spec.port == BRIGHTNESS:
if spec.dtype not in (DType.FLOAT, DType.INT):
raise ValueError(f"'{BRIGHTNESS}' is a float or an int.")
continue
device = _device(spec.port, areas)
if device is None:
raise ValueError(
f"'{spec.port}' names no device wren knows. A target is "
f"<area>_<kind>, the area one of {', '.join(areas)}, the kind "
f"one of {', '.join(KIND_WORDS)}."
)
if device[1] not in wren.KINDS: # switches came with wren's s10
raise ValueError(
f"'{spec.port}' is a {device[1]}, which this wren does not know."
)
else:
ports.append((spec, False))
for spec in inputs:
if spec.port != PROMPT:
ports.append((spec, True))
elif spec.dtype is not DType.STR:
raise ValueError(f"'{PROMPT}' is a str.")
#: Each target by its port, as a model's ``meta`` lists the targets it
#: was trained on.
self.targets: dict[str, dict[str, Any]] = {}
for spec, readonly in ports:
if spec.dtype not in _TARGET_TYPES:
raise ValueError(
f"'{spec.port}' is {spec.dtype.value}; a target is bool, int, "
"float or str."
)
self.devices.setdefault(device, []).append(spec.port)
info = self.cfg.targets.get(spec.port, TargetInfo())
self.targets[spec.port] = {
"name": spec.port,
"type": spec.dtype.value,
"values": info.values,
"range": None if info.range is None else list(info.range),
"description": info.description,
"unit": info.unit,
"readonly": readonly,
}
for port, info in self.cfg.targets.items():
kind = self.targets[port]["type"] if port in self.targets else None
if kind is None:
raise ValueError(f"'targets' names '{port}', which no target port is.")
if info.values is not None and kind != "str":
raise ValueError(f"'{port}' is {kind}; only a str target has values.")
if info.range is not None and kind not in ("int", "float"):
raise ValueError(f"'{port}' is {kind}; only a number has a range.")
# Set on start, from the model and the targets it was trained on.
self.pack: dict[str, Any] | None = None
self.untrained: set[str] = set()
super().__init__(
self._act,
# The prompt is the event; the rest is read when it arrives.
requires=[
spec.model_copy(update={"trigger": spec.port == "prompt"})
spec.model_copy(update={"trigger": spec.port == PROMPT})
for spec in inputs
],
provides=outputs,
@@ -493,130 +339,141 @@ class WrenNode(Node):
name=name or "wren",
)
def _house(
self,
wren: Any,
specs: dict[str, MessageSpec],
presence: str | None,
outdoor: float | None,
dark: bool | None,
) -> Any:
"""The house as the model reads it, each device in its current state."""
names = [specs[port].name for ports in self.devices.values() for port in ports]
current = (
self._pipeline.state.get_present(names)
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)
def _load(self, store: Any) -> dict[str, Any] | None:
"""The model, read once, or None when there is none to run.
A target it was not trained on, or one described otherwise since, is
a word it never learnt, so a call on it is never applied.
"""
wren = _wren()
path = wren.runtime.MODEL
if self.cfg.model:
path = store.path(self.cfg.model) if store is not None else None
if path is None:
self.report_health(
"degraded",
f"model {self.cfg.model[:19]}… not found, every command goes on",
)
return None
try:
pack: dict[str, Any] = wren.load_pack(path)
except Exception as exc:
self.report_health(
"degraded", f"model unreadable ({exc}), every command goes on"
)
return None
known = {t.get("name"): _learnt(t) for t in pack["meta"].get("targets", [])}
self.untrained = {
port for port, t in self.targets.items() if known.get(port) != _learnt(t)
}
if self.untrained:
self.report_health(
"degraded",
f"the model needs retraining for {', '.join(sorted(self.untrained))}; "
"commands on them go on",
)
else:
self.report_health("ok")
return pack
def _current(self, readings: dict[str, Any]) -> dict[str, Any]:
"""Each target's value now, by port: an output's from the state, a
reading's from its input."""
ports = {
spec.name: spec.port
for spec in self.output_ports
if spec.name and spec.port in self.targets
}
present = (
self._pipeline.state.get_present(list(ports))
if self._pipeline is not None
else {}
)
areas: dict[str, list[Any]] = {}
for area in wren.AREAS:
wired = {kind for (a, kind) in self.devices if a == area}
kinds = wired | set(_TRAINED.get(area, ("light",)))
areas[area] = []
for kind in (k for k in wren.KINDS if k in kinds):
ports = self.devices.get((area, kind), [])
held = [
current[specs[p].name] for p in ports if specs[p].name in current
]
# The id is ours to choose: the model is shown aliases anyway.
areas[area].append(
wren.Device(kind, f"{area}:{kind}", _state(kind, held))
)
clock = time.localtime()
where = str(presence or "").strip().lower().replace("_", " ")
return wren.House(
time=time.strftime("%H:%M", clock),
# The range the model was trained on; past it a number is unfamiliar.
outdoor_c=min(max(float(20 if outdoor is None else outdoor), -5), 30),
dark=clock.tm_hour < 7 or clock.tm_hour >= 18 if dark is None else dark,
user_area=where if where in wren.AREAS else None,
areas=areas,
)
return {ports[name]: value for name, value in present.items()} | readings
def _bar(self, port: str, threshold: float) -> float:
"""The confidence a call on ``port`` needs: the target's own, the
node's, or else the model's."""
info = self.cfg.targets.get(port, TargetInfo())
bars = (info.min_confidence, self.cfg.min_confidence, threshold)
return next(bar for bar in bars if bar is not None)
def _act(
self,
params: dict[str, Any],
prompt: str = "",
presence: str | None = None,
outdoor: float | None = None,
dark: bool | None = None,
**readings: float | None,
self, params: dict[str, Any], prompt: str = "", **readings: Any
) -> dict[str, Any] | None:
if not prompt.strip():
# An empty command is nothing to do, which is what a flow input
# starts as.
return None
wren = _wren()
specs = {spec.port: spec for spec in self.output_ports}
house = self._house(wren, specs, presence, outdoor, dark)
prompt = _aliased(prompt, self.cfg.aliases)
command = _digits(_plain(prompt))
asked = _asked(house, command)
group = self._group(command, house.areas)
if group is not None:
calls, confidence, asked = group, 1.0, set(house.areas)
else:
current = self._current(readings)
calls: list[Any] = []
threshold = 1.0
if self.pack is not None:
wren = _wren()
targets = [
wren.Target(
**t, value=_unset(t) if current.get(p) is None else current[p]
)
for p, t in self.targets.items()
]
# One BLAS thread answers in about half the time for a model
# this small. Scoped to the call, so numpy elsewhere in the
# process keeps its threads.
with threadpool_limits(1):
calls, confidence = wren.act(command, house)
confidence = round(float(confidence), 3)
write = self.cfg.apply and confidence >= self.cfg.min_confidence
calls = wren.act(command, targets, model=self.pack)
threshold = self.pack["meta"].get("threshold", threshold)
# All of a command or none of it: applying a part leaves half of a
# duration ("off for 7 minutes": off now, back on later), or lets the
# assistant do again what was applied. `apply` only holds back writes:
# a reading is answered either way.
sure = all(
call.confidence >= self._bar(call.target, threshold)
and call.target not in self.untrained
and not self.cfg.targets.get(call.target, TargetInfo()).pass_on
# A reading nothing has written yet has no answer.
and (call.value is not None or current.get(call.target) is not None)
for call in calls
)
specs = {spec.port: spec for spec in [*self.input_ports, *self.output_ports]}
writes: dict[str, Any] = {}
actions: list[dict[str, Any]] = []
said: list[str] = []
answers: list[str] = []
for call in calls:
area = str(call.args.get("area", ""))
if (
call.tool == "sensors.temperature"
and area in asked
and confidence >= self.cfg.min_confidence
and (answer := self._temperature(area, outdoor, readings))
):
answers.append(answer)
area, _, kind = str(call.args.get("id", "")).partition(":")
ports = self.devices.get((area, kind), [])
if call.tool.split(".")[0] != _TOOLS.get(kind) or area not in asked:
ports = []
if call.tool in _LEVELS:
ports = []
if BRIGHTNESS in specs:
ports = [BRIGHTNESS]
call = self._leveled(call, specs[BRIGHTNESS].name)
hits = [
(specs[port], value)
for port in ports
if (value := _value(call, specs[port].dtype)) is not None
]
record = {
"message": "",
"value": None,
"applied": False,
"delay_s": call.delay_s,
"call": _text(call),
"confidence": confidence,
}
if not hits:
actions.append(record)
done = False
for spec, value in hits:
applied = write
spec, unit = specs[call.target], self.targets[call.target]["unit"]
applied = sure if call.value is None else sure and self.cfg.apply
if call.value is None:
value = current.get(call.target)
if applied:
words = call.target.replace("_", " ").capitalize()
answers.append(f"{words} is {_spoken(value, unit)}.")
else:
value = _typed(call.value, spec.dtype)
if applied and call.delay_s:
applied = self._pipeline is not None and self._pipeline.defer(
self, self._to_messages({spec.port: value}) or {}, call.delay_s
)
elif applied:
writes[spec.port] = value
actions.append(
record | {"message": spec.name, "value": value, "applied": applied}
)
done = done or applied
if done:
said.append(_said(call, area))
if group is not None and said:
said = self._group_said(group, command, house.areas)
if applied:
said.append(_said(call.target, value, unit, call.delay_s))
actions.append(
{
"message": spec.name,
"value": value,
"applied": applied,
"delay_s": call.delay_s,
"call": _text(call),
"confidence": round(float(call.confidence), 3),
}
)
wired = {spec.port for spec in self.output_ports}
if ACTIONS in wired:
@@ -632,77 +489,6 @@ class WrenNode(Node):
writes[REPLY] = CANT
return writes or None
def _leveled(self, call: Any, name: str) -> _Call:
"""A brightness call as the level it sets: "dim" is 30 below now."""
level = call.args.get("level")
if call.tool != "lights.set":
now = (
self._pipeline.state.get_present([name]).get(name)
if self._pipeline is not None
else None
)
if not isinstance(now, (int, float)) or isinstance(now, bool):
now = _USUAL
if call.tool == "lights.dim":
level = max(now - _STEP, min(now, _DIMMEST))
else:
level = min(now + _STEP, _BRIGHTEST)
elif isinstance(level, (int, float)):
level = min(max(level, 0), _BRIGHTEST)
return _Call("lights.set", {"level": level}, call.delay_s)
def _group(self, command: str, areas: Iterable[str]) -> list[_Call] | None:
"""The calls a command for a whole group means, one per wired device,
or None when it is not one: "turn off all lights" is every light.
Not the model's to answer: it makes one call of it, and "turn off all
lights" switched a living room light on at 0.98.
"""
action = re.search(rf"\b({'|'.join(_GROUP_TOOLS)})\b", command)
if not _GROUP.search(command) or _NOT_GROUP.search(command) or not action:
return None
tools = _GROUP_TOOLS[action[1]]
kinds = {_GROUP_KINDS[w] for w in command.split() if w in _GROUP_KINDS}
named = _named(areas, command)
delay = re.search(r"\bin (\d+) minutes?\b", command)
seconds = int(delay[1]) * 60.0 if delay else None
calls = [
_Call(tools[kind], {"id": f"{area}:{kind}"}, seconds)
for area, kind in self.devices
if kind in tools
and (not kinds or kind in kinds)
and (not named or area in named)
]
return calls or None
@staticmethod
def _group_said(
calls: list[_Call], command: str, areas: Iterable[str]
) -> list[str]:
"""A group command's reply: "all lights off", "lights off in the office"."""
said = list(dict.fromkeys(_GROUP_SAID[call.tool] for call in calls))
named = _named(areas, command)
if named:
said[-1] += " in the " + " and the ".join(named)
else:
said[0] = "all " + said[0]
if calls[0].delay_s:
minutes = round(calls[0].delay_s / 60)
said[-1] += f" in {minutes} minute{'s' * (minutes != 1)}"
return said
def _temperature(
self, area: str, outdoor: float | None, readings: dict[str, float | None]
) -> str | None:
""" "It's 8 degrees outside.", from the reading wired for the area."""
value = readings.get(self.readings.get(area, ""))
where = f"in the {area}"
if value is None and area in _OUTSIDE:
value, where = outdoor, "outside"
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
return f"It's {round(value)} degrees {where}."
def _passed_on(self) -> bool:
"""Whether a node reads ``unhandled``, such as an assistant."""
name = next((s.name for s in self.output_ports if s.port == UNHANDLED), "")
+35 -22
View File
@@ -1,5 +1,7 @@
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
@@ -12,40 +14,51 @@ def db() -> Generator[None, None, None]:
@dataclass
class _Device:
kind: str
id: str
state: str
class FakeTarget:
name: str
type: str
value: Any
values: list[str] | None = None
range: list[float] | None = None
description: str = ""
unit: str | None = None
readonly: bool = False
@dataclass
class _House:
time: str
outdoor_c: float
dark: bool
user_area: str | None
areas: dict[str, list[_Device]]
class FakeCall:
target: str
value: Any = None
delay_s: float | None = None
confidence: float = 1.0
class FakeWren:
"""The model package's surface, answering whatever a test sets ``answer`` to."""
"""The model package's surface: ``act`` answers whatever a test sets
``answer`` to, and ``load_pack`` returns ``pack``."""
AREAS = [
*("living room", "kitchen", "bathroom", "bedroom", "office"),
*("garden", "patio", "garage", "driveway", "balcony"),
]
KINDS = ["light", "media", "heat", "shutter", "water", "switch"]
Device = _Device
House = _House
Target = FakeTarget
Call = FakeCall
runtime = SimpleNamespace(MODEL=Path("demo.bin"))
def __init__(self) -> None:
self.answer: tuple[list[Any], float] = ([], 1.0)
self.houses: list[_House] = []
self.answer: list[FakeCall] = []
self.pack: dict[str, Any] = {"meta": {"threshold": 0.9, "targets": []}}
self.loaded: list[Path] = []
self.commands: list[str] = []
self.targets: list[list[FakeTarget]] = []
self.models: list[Any] = []
def act(self, command: str, house: _House) -> tuple[list[Any], float]:
self.houses.append(house)
def load_pack(self, path: Path) -> dict[str, Any]:
self.loaded.append(path)
return self.pack
def act(
self, command: str, targets: list[FakeTarget], *, model: Any = None
) -> list[FakeCall]:
self.commands.append(command)
self.targets.append(targets)
self.models.append(model)
return self.answer
+343 -244
View File
@@ -4,79 +4,96 @@ The model is an optional package, so ``fake_wren`` (conftest) answers in its
place: what the node does with an answer is what is under test here.
"""
from dataclasses import dataclass, field
import asyncio
from types import SimpleNamespace
from typing import Any
import pytest
from fluksio.flow.artifacts import ArtifactStore
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes.base import Node
from fluksio.flow.nodes.wren import CANT, WrenNode, _plain, _value
from fluksio.flow.nodes.wren import CANT, WrenNode, _plain
from fluksio.flow.pipeline import Pipeline
@dataclass
class FakeCall:
tool: str
args: dict[str, Any] = field(default_factory=dict)
delay_s: float | None = None
PROMPT = MessageSpec(name="command", port="prompt", dtype=DType.STR)
OUTDOOR = MessageSpec(name="outdoor", port="outdoor_temperature", dtype=DType.FLOAT)
LIGHT = MessageSpec(name="kitchen_light", dtype=DType.BOOL)
def _node(*targets: tuple[str, DType], **params: object) -> WrenNode:
def _node(
fake_wren,
*targets: tuple[str, DType],
inputs=(),
saying=False,
trained=None,
health=None,
app=None,
**params: Any,
) -> WrenNode:
"""A started node, its model trained on its own targets unless ``trained``
lists others."""
node = WrenNode(
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR)],
requires=[PROMPT, *inputs],
provides=[
MessageSpec(name="actions", dtype=DType.LIST),
*(MessageSpec(name=port, dtype=dtype) for port, dtype in targets),
*(
MessageSpec(name=port, dtype=DType.STR)
for port in ("reply", "unhandled")
if saying
),
],
params=params,
)
node.assign_flow("house", "control")
if health is not None:
node._on_health = lambda _node, status, detail: health.append((status, detail))
fake_wren.pack["meta"]["targets"] = (
list(node.targets.values()) if trained is None else trained
)
asyncio.run(node.start(app))
return node
def test_targets_of_one_area_and_kind_are_one_device(fake_wren):
node = _node(
("living_room_setpoint", DType.FLOAT),
("living_room_boost", DType.BOOL),
("kitchen_lamp", DType.BOOL),
("office_plug", DType.BOOL),
)
assert node.devices == {
("living room", "heat"): ["living_room_setpoint", "living_room_boost"],
("kitchen", "light"): ["kitchen_lamp"],
("office", "switch"): ["office_plug"],
}
def _record(port: str, value: Any, call: str, **more: Any) -> dict[str, Any]:
return {
"message": f"house.{port}",
"value": value,
"applied": True,
"delay_s": None,
"call": call,
"confidence": 1.0,
} | more
def test_a_kind_the_model_does_not_know_is_refused(fake_wren, monkeypatch):
monkeypatch.setattr(fake_wren, "KINDS", ["light"])
with pytest.raises(ValueError, match="switch"):
_node(("office_plug", DType.BOOL))
@pytest.mark.parametrize("port", ["hallway_light", "kitchen_fridge", "light"])
def test_a_port_naming_no_trained_device_is_refused(fake_wren, port):
with pytest.raises(ValueError, match="living_room") as refused:
_node((port, DType.BOOL))
assert port in str(refused.value)
def test_a_prompt_is_required(fake_wren):
with pytest.raises(ValueError, match="prompt"):
WrenNode(provides=[MessageSpec(name="kitchen_light", dtype=DType.BOOL)])
@pytest.mark.parametrize(
("requires", "provides", "params", "refusal"),
[
([], [LIGHT], {}, "prompt"),
([PROMPT], [MessageSpec(name="plan", dtype=DType.JSON)], {}, "plan"),
([PROMPT], [LIGHT], {"targets": {"hall_light": {}}}, "hall_light"),
(
[PROMPT],
[LIGHT],
{"targets": {"kitchen_light": {"values": ["ON"]}}},
"values",
),
([PROMPT], [LIGHT], {"targets": {"kitchen_light": {"range": [0, 1]}}}, "range"),
],
)
def test_a_wiring_wren_cannot_act_on_is_refused(
fake_wren, requires, provides, params, refusal
):
with pytest.raises(ValueError, match=refusal):
WrenNode(requires=requires, provides=provides, params=params)
def test_only_the_prompt_wakes_it(fake_wren):
node = WrenNode(
requires=[
MessageSpec(name="command", port="prompt", dtype=DType.STR),
MessageSpec(name="outside", port="outdoor", dtype=DType.FLOAT),
],
)
node = WrenNode(requires=[PROMPT, OUTDOOR])
assert {spec.port: spec.trigger for spec in node.input_ports} == {
"prompt": True,
"outdoor": False,
"outdoor_temperature": False,
}
@@ -85,83 +102,284 @@ def test_the_model_reads_the_command_plain():
assert _plain("Heat it to 22.5, please!") == "heat it to 22.5 please"
@pytest.mark.parametrize(
("tool", "args", "dtype", "value"),
[
("lights.on", {}, DType.BOOL, True),
("lights.on", {}, DType.FLOAT, 100.0),
("lights.off", {}, DType.STR, "off"),
("heating.set", {"temp": 22}, DType.FLOAT, 22.0),
("heating.set", {"temp": 22}, DType.BOOL, None),
("heating.boost", {}, DType.BOOL, True),
("heating.boost", {}, DType.INT, None),
("switches.off", {}, DType.INT, 0),
("sensors.temperature", {"area": "kitchen"}, DType.FLOAT, None),
],
)
def test_a_call_means_what_its_target_type_can_hold(tool, args, dtype, value):
assert _value(FakeCall(tool, args), dtype) == value
def test_applied_calls_reach_their_targets(fake_wren):
def test_the_targets_reach_the_model_with_their_values_and_settings(fake_wren):
node = _node(
("living_room_setpoint", DType.FLOAT),
("living_room_boost", DType.BOOL),
fake_wren,
("kitchen_light", DType.BOOL),
("living_room_heating", DType.FLOAT),
("kitchen_shutter", DType.STR),
inputs=[OUTDOOR],
targets={
"living_room_heating": {"range": [5, 30], "unit": "°C"},
"kitchen_shutter": {"values": ["UP", "DOWN"]},
"outdoor_temperature": {"description": "temperature outside"},
},
)
Pipeline([node]).state["house.kitchen_light"] = True
node.execute({"house.command": "warm it up", "house.outdoor": 6.2})
assert fake_wren.loaded == [fake_wren.runtime.MODEL]
assert fake_wren.models == [fake_wren.pack]
# Targets nothing has set yet are off, 0 within their range, or their
# first value.
target = fake_wren.Target
assert fake_wren.targets[0] == [
target("kitchen_light", "bool", True),
target("living_room_heating", "float", 5.0, range=[5.0, 30.0], unit="°C"),
target("kitchen_shutter", "str", "UP", values=["UP", "DOWN"]),
target(
"outdoor_temperature",
"float",
6.2,
description="temperature outside",
readonly=True,
),
]
def test_applied_calls_reach_their_targets_and_the_reply_says_so(fake_wren):
node = _node(
fake_wren,
("kitchen_light", DType.BOOL),
("living_room_heating", DType.FLOAT),
saying=True,
apply=True,
targets={"living_room_heating": {"range": [5, 30], "unit": "°C"}},
)
Pipeline([node]).state["house.living_room_setpoint"] = 21.0
fake_wren.answer = (
[
FakeCall("heating.set", {"id": "living room:heat", "temp": 23}),
# The living room's light is filled in for the model, not wired.
FakeCall("lights.on", {"id": "living room:light"}),
# 0.95 clears the model's own threshold, 0.9 here.
fake_wren.answer = [
fake_wren.Call("kitchen_light", True, confidence=0.95),
fake_wren.Call("living_room_heating", 21),
]
out = node.execute({"house.command": "kitchen light on and the heating to 21"})
assert out == {
"house.kitchen_light": True,
"house.living_room_heating": 21.0,
"house.reply": "Kitchen light on, living room heating to 21 °C.",
"house.actions": [
_record("kitchen_light", True, "set(kitchen_light, true)", confidence=0.95),
_record("living_room_heating", 21.0, "set(living_room_heating, 21)"),
],
0.98,
)
out = node.execute({"house.command": "set the heating to 23 degrees"})
assert out is not None
assert out["house.living_room_setpoint"] == 23.0
assert "house.living_room_boost" not in out
assert [(a["message"], a["applied"]) for a in out["house.actions"]] == [
("house.living_room_setpoint", True),
("", False),
]
# Areas nothing is wired to are shown too, so a call can name them.
assert set(fake_wren.houses[0].areas) == set(fake_wren.AREAS)
living = fake_wren.houses[0].areas["living room"]
assert [(d.kind, d.state) for d in living] == [
("light", "off"),
("media", "off"),
("heat", "21C"),
]
}
assert isinstance(out["house.living_room_heating"], float)
@pytest.mark.parametrize(
("params", "confidence"),
[({}, 0.99), ({"apply": True}, 0.5), ({"apply": True, "min_confidence": 1}, 0.99)],
[
({}, 0.99),
# Below the model's own threshold, 0.9 here.
({"apply": True}, 0.85),
({"apply": True, "min_confidence": 1}, 0.99),
],
)
def test_nothing_is_written_unless_applied_and_sure(fake_wren, params, confidence):
node = _node(("kitchen_light", DType.BOOL), **params)
fake_wren.answer = ([FakeCall("lights.on", {"id": "kitchen:light"})], confidence)
node = _node(fake_wren, ("kitchen_light", DType.BOOL), **params)
fake_wren.answer = [fake_wren.Call("kitchen_light", True, confidence=confidence)]
out = node.execute({"house.command": "turn on the lights in the kitchen"})
out = node.execute({"house.command": "turn on the kitchen light"})
assert out == {
"house.actions": [
{
"message": "house.kitchen_light",
"value": True,
"applied": False,
"delay_s": None,
"call": "lights.on()",
"confidence": confidence,
}
_record(
"kitchen_light",
True,
"set(kitchen_light, true)",
applied=False,
confidence=confidence,
)
]
}
@pytest.mark.parametrize(
("params", "applied"),
[
({}, True),
# One call short of its target's bar stops the whole command.
({"targets": {"kitchen_light": {"min_confidence": 0.99}}}, False),
({"targets": {"kitchen_light": {"pass_on": True}}}, False),
({"min_confidence": 0.99}, False),
# A target's own bar is taken over the node's.
(
{
"min_confidence": 0.99,
"targets": {
"kitchen_light": {"min_confidence": 0.9},
"garden_light": {"min_confidence": 0.9},
},
},
True,
),
],
)
def test_a_command_is_applied_whole_or_not_at_all(fake_wren, params, applied):
node = _node(
fake_wren,
("kitchen_light", DType.BOOL),
("garden_light", DType.BOOL),
saying=True,
apply=True,
**params,
)
fake_wren.answer = [
fake_wren.Call("garden_light", True, confidence=0.95),
fake_wren.Call("kitchen_light", True, confidence=0.95),
]
command = "garden and kitchen lights on"
out = node.execute({"house.command": command})
assert out is not None
assert [a["applied"] for a in out.pop("house.actions")] == [applied, applied]
assert out == (
{
"house.garden_light": True,
"house.kitchen_light": True,
"house.reply": "Garden light on, kitchen light on.",
}
if applied
else {"house.unhandled": command, "house.reply": CANT}
)
def test_a_target_the_model_was_not_trained_on_is_never_applied(fake_wren):
trained = [
# As the pack writes it: [5, 30] is the node's (5.0, 30.0), and the
# unit is not the model's to know.
{
"name": "living_room_heating",
"type": "float",
"values": None,
"range": [5, 30],
"description": "",
"unit": None,
"readonly": False,
},
# Described otherwise since.
{
"name": "canopy",
"type": "str",
"values": ["UP", "DOWN", "STOP"],
"range": None,
"description": "awning",
"unit": None,
"readonly": False,
},
]
health: list[tuple[str, str | None]] = []
node = _node(
fake_wren,
("living_room_heating", DType.FLOAT),
("canopy", DType.STR),
("garden_light", DType.BOOL),
saying=True,
trained=trained,
health=health,
apply=True,
targets={
"living_room_heating": {"range": [5, 30], "unit": "°C"},
"canopy": {
"values": ["UP", "DOWN", "STOP"],
"description": "awning over the patio",
},
},
)
((status, detail),) = health
assert status == "degraded"
assert detail is not None and "retraining for canopy, garden_light" in detail
fake_wren.answer = [fake_wren.Call("canopy", "DOWN")]
out = node.execute({"house.command": "close the awning"})
assert out is not None
assert [a["applied"] for a in out["house.actions"]] == [False]
assert out["house.unhandled"] == "close the awning"
def test_a_read_is_answered_from_the_target_value(fake_wren):
node = _node(
fake_wren,
inputs=[OUTDOOR],
saying=True,
# Not applied: a reading is answered anyway, as it writes nothing.
targets={"outdoor_temperature": {"unit": "°C"}},
)
fake_wren.answer = [fake_wren.Call("outdoor_temperature")]
command = "how warm is it outside"
out = node.execute({"house.command": command, "house.outdoor": 6.2})
assert out == {
"house.reply": "Outdoor temperature is 6.2 °C.",
"house.actions": [
_record("outdoor", 6.2, "read(outdoor_temperature)"),
],
}
# Nothing has written the reading yet: there is no answer to give.
out = node.execute({"house.command": command})
assert out is not None
assert out["house.unhandled"] == command
def test_a_delayed_call_is_held_by_the_work_queue(fake_wren, monkeypatch):
node = _node(fake_wren, ("kitchen_light", DType.BOOL), saying=True, apply=True)
deferred: list[tuple[dict[str, Any], float]] = []
def defer(self, node, outputs, seconds):
deferred.append((outputs, seconds))
return True
monkeypatch.setattr(Pipeline, "defer", defer)
Pipeline([node])
fake_wren.answer = [
fake_wren.Call("kitchen_light", False),
fake_wren.Call("kitchen_light", True, delay_s=420),
]
out = node.execute({"house.command": "kitchen light off for 7 minutes"})
assert deferred == [({"house.kitchen_light": True}, 420)]
assert out is not None
assert out["house.kitchen_light"] is False
assert out["house.reply"] == "Kitchen light off, kitchen light on in 7 minutes."
def test_a_trained_model_is_read_from_the_artifact_store(fake_wren, tmp_path):
store = ArtifactStore(tmp_path)
digest = store.put([b"WREN"])["digest"]
app = SimpleNamespace(state=SimpleNamespace(artifact_store=store))
health: list[tuple[str, str | None]] = []
_node(
fake_wren, ("kitchen_light", DType.BOOL), app=app, health=health, model=digest
)
assert fake_wren.loaded == [store.path(digest)]
assert health == [("ok", None)]
# One the store does not have: every command goes on.
node = _node(
fake_wren,
("kitchen_light", DType.BOOL),
saying=True,
app=app,
health=health,
apply=True,
model="sha256:" + "0" * 64,
)
assert health[-1][0] == "degraded"
out = node.execute({"house.command": "kitchen light on"})
assert out == {
"house.actions": [],
"house.unhandled": "kitchen light on",
"house.reply": CANT,
}
assert fake_wren.commands == []
def test_the_model_runs_on_one_blas_thread(fake_wren, monkeypatch):
"""About twice as fast for a model this small; numpy is loaded by `mlp`."""
from threadpoolctl import threadpool_info
@@ -169,163 +387,44 @@ def test_the_model_runs_on_one_blas_thread(fake_wren, monkeypatch):
threads: list[int] = []
act = fake_wren.act
def counted(command: str, house: Any) -> tuple[list[Any], float]:
def counted(command: str, targets: Any, *, model: Any = None) -> Any:
threads.extend(
pool["num_threads"]
for pool in threadpool_info()
if pool["user_api"] == "blas"
)
return act(command, house)
return act(command, targets, model=model)
monkeypatch.setattr(fake_wren, "act", counted)
_node(("kitchen_lamp", DType.BOOL)).execute({"house.command": "lights on"})
node = _node(fake_wren, ("kitchen_light", DType.BOOL))
node.execute({"house.command": "lights on"})
assert threads
assert set(threads) == {1}
def test_a_reply_says_what_was_applied_and_the_rest_is_unhandled(fake_wren):
node = WrenNode(
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR)],
provides=[
MessageSpec(name="kitchen_light", dtype=DType.BOOL),
MessageSpec(name="reply", dtype=DType.STR),
MessageSpec(name="unhandled", dtype=DType.STR),
],
params={"apply": True},
def test_own_words_and_number_words_reach_the_model_as_its_own(fake_wren):
node = _node(
fake_wren,
("garden_light", DType.BOOL),
saying=True,
apply=True,
aliases={"Page Too": "garden"},
)
node.assign_flow("house", "control")
fake_wren.answer = ([FakeCall("lights.on", {"id": "kitchen:light"})], 0.99)
out = node.execute({"house.command": "turn on the kitchen light"})
assert out == {"house.kitchen_light": True, "house.reply": "Kitchen light on."}
# Nothing wired for the call, or nothing to call at all. Nothing reads
# what is left, so the node says so itself.
asked = "what is the weather tomorrow"
for calls in ([FakeCall("lights.on", {"id": "garage:light"})], []):
fake_wren.answer = (calls, 0.99)
out = node.execute({"house.command": asked})
assert out == {"house.unhandled": asked, "house.reply": CANT}
# An assistant reading it answers instead.
unhandled = MessageSpec(name="house.unhandled", port="unhandled", dtype=DType.STR)
Pipeline([node, Node(lambda params, unhandled: None, requires=[unhandled])])
assert node.execute({"house.command": asked}) == {"house.unhandled": asked}
def test_a_call_on_an_area_the_command_does_not_name_is_not_applied(fake_wren):
"""A word the model never learned lands on a neighbour it was shown."""
node = _node(("bathroom_light", DType.BOOL), apply=True)
fake_wren.answer = ([FakeCall("lights.on", {"id": "bathroom:light"})], 0.97)
out = node.execute({"house.command": "switch on the appliances"})
assert out is not None
assert "house.bathroom_light" not in out
assert [a["applied"] for a in out["house.actions"]] == [False]
def _speaking(*targets: tuple[str, DType], inputs=(), **params: object) -> WrenNode:
node = WrenNode(
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR), *inputs],
provides=[
*(MessageSpec(name=port, dtype=dtype) for port, dtype in targets),
MessageSpec(name="reply", dtype=DType.STR),
MessageSpec(name="unhandled", dtype=DType.STR),
],
params={"apply": True, **params},
)
node.assign_flow("house", "control")
return node
def test_the_house_words_and_number_words_reach_the_model_as_its_own(fake_wren):
node = _speaking(("patio_light", DType.BOOL), aliases={"Page Too": "patio"})
fake_wren.answer = ([], 0.99)
out = node.execute({"house.command": "Turn on the page too light in five minutes."})
assert fake_wren.commands == ["turn on the patio light in 5 minutes"]
# The assistant reads the house's words too, as they were said otherwise.
assert fake_wren.commands == ["turn on the garden light in 5 minutes"]
# The assistant reads the own words too, as they were said otherwise.
passed = "Turn on the garden light in five minutes."
assert out == {
"house.unhandled": "Turn on the patio light in five minutes.",
"house.actions": [],
"house.unhandled": passed,
"house.reply": CANT,
}
def test_a_command_for_all_of_a_kind_reaches_every_wired_one(fake_wren):
lights = [(f"{area}_light", DType.BOOL) for area in ("kitchen", "office")]
node = _speaking(*lights, ("office_shutter", DType.STR))
out = node.execute({"house.command": "Turn off all lights."})
assert out == {
"house.kitchen_light": False,
"house.office_light": False,
"house.reply": "All lights off.",
}
out = node.execute({"house.command": "turn everything off in the office"})
assert out == {
"house.office_light": False,
"house.reply": "Lights off in the office.",
}
assert fake_wren.commands == [] # the node's, not the model's
# A question or an exception is the model's again.
fake_wren.answer = ([], 0.99)
node.execute({"house.command": "are all lights off"})
node.execute({"house.command": "turn off all lights but the kitchen"})
assert len(fake_wren.commands) == 2
def test_a_question_for_a_temperature_is_answered_from_its_reading(fake_wren):
node = _speaking(
inputs=[
MessageSpec(name="outside", port="outdoor", dtype=DType.FLOAT),
MessageSpec(
name="kitchen_temp", port="kitchen_temperature", dtype=DType.FLOAT
),
]
)
assert node.readings == {"kitchen": "kitchen_temperature"}
readings = {"house.outside": 7.6, "house.kitchen_temp": 21.2}
for area, said in [("garden", "outside"), ("kitchen", "in the kitchen")]:
fake_wren.answer = ([FakeCall("sensors.temperature", {"area": area})], 0.99)
command = f"what is the temperature in the {area}"
out = node.execute({"house.command": command, **readings})
degrees = 8 if area == "garden" else 21
assert out == {"house.reply": f"It's {degrees} degrees {said}."}
# No reading for the bedroom, or a model unsure of it: the assistant's.
for area, confidence in [("bedroom", 0.99), ("kitchen", 0.5)]:
fake_wren.answer = (
[FakeCall("sensors.temperature", {"area": area})],
confidence,
)
command = f"how warm is it in the {area}"
out = node.execute({"house.command": command, **readings})
assert out == {"house.unhandled": command, "house.reply": CANT}
def test_a_brightness_call_sets_the_house_brightness(fake_wren):
node = _speaking(("brightness", DType.INT))
# Whatever area the call names: the brightness is the house's.
for call, level in [
(FakeCall("lights.set", {"id": "kitchen:light", "level": 30}), 30),
(FakeCall("lights.dim", {"id": "kitchen:light"}), 40),
(FakeCall("lights.brighten", {"id": "bedroom:light"}), 100),
]:
fake_wren.answer = ([call], 0.95)
out = node.execute({"house.command": "dim the lights in the kitchen"})
assert out == {
"house.brightness": level,
"house.reply": f"Brightness at {level} percent.",
}
# Nothing wired for it: the assistant's.
node = _speaking(("kitchen_light", DType.BOOL))
fake_wren.answer = ([FakeCall("lights.dim", {"id": "kitchen:light"})], 0.95)
out = node.execute({"house.command": "dim the lights"})
assert out == {"house.unhandled": "dim the lights", "house.reply": CANT}
# An assistant reading `unhandled` answers instead.
unhandled = MessageSpec(name="house.unhandled", port="unhandled", dtype=DType.STR)
Pipeline([node, Node(lambda params, unhandled: None, requires=[unhandled])])
out = node.execute({"house.command": "Turn on the page too light in five minutes."})
assert out == {"house.actions": [], "house.unhandled": passed}