Files
stroblmeandClaude Opus 5 831a537980 Stop a quiet producer vetoing a noisy one in the same wave
Both boilers on the house had been unable to switch on since the Node-RED
transition, and the reason was here rather than in their logic: the command
reached `boiler.water_boiler` and stopped, because `dmx.switches` never ran.

A wave orders nodes by a dependency count, and two things decremented that
count only on success:

- a node that published nothing — rate limited, unchanged, or failed — never
  freed its consumers. `dmx.switches` reads both boilers through `rbe` nodes,
  so the kitchen one being unchanged, which it is nearly always, held the main
  one's command back. The encoder ran about four times an hour, and only when
  the lights happened to change in the same wave.
- a node that could not run at all never freed them either, permanently.
  `plugs.pump_run` waits on a watering pulse that only exists at 02:00, so
  every wave it appeared in took its consumers out with it.

Freeing a consumer is not the same as running it: `untouched` already refuses
to run anything whose inputs nothing refreshed, and that is the accurate test.
The dependency count is ordering, not permission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
2026-08-28 16:53:49 +02:00

356 lines
11 KiB
Python

"""The wiring fundamentals: name binding, fan-in, namespaces, validation."""
from fluksio.flow.controller import _declared_inputs
from fluksio.flow.events import EventBus
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import Pipeline, ValidationIssue
from fluksio.flow.schemas import FlowDef, FlowInput
def spec(name: str, dtype: DType = DType.FLOAT, port: str = "") -> MessageSpec:
return MessageSpec(name=name, dtype=dtype, port=port)
def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node:
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
node.assign_flow(flow, node_id)
return node
def test_bare_names_are_scoped_to_their_flow():
source = make_node(
"source", "heating", lambda params: {"temp": 20.0}, provides=[spec("temp")]
)
assert "heating.temp" in source.provides
def test_consumer_receives_from_any_producer():
# Two producers of one message: each publication reaches the consumer.
seen = []
def emit_a(params):
return {"temp": 1.0}
def emit_b(params):
return {"temp": 2.0}
def consume(temp, params):
seen.append(temp)
return None
a = make_node("a", "heating", emit_a, provides=[spec("temp")])
b = make_node("b", "heating", emit_b, provides=[spec("temp")])
c = make_node("c", "heating", consume, requires=[spec("temp")])
pipeline = Pipeline(nodes=[a, b, c])
assert pipeline.produces["heating.temp"] == [a, b]
assert pipeline.dependencies[c] == frozenset({a, b})
a.inject()
b.inject()
assert seen == [1.0, 2.0]
# Latest value wins.
assert pipeline.state["heating.temp"] == 2.0
def test_flows_connect_through_qualified_names():
def emit(params):
return {"power": 500.0}
def consume(power, params):
return {"used": power}
producer = make_node("meter", "solar", emit, provides=[spec("power")])
# A bare name would be heating.power; the dotted one crosses the flow.
consumer = make_node(
"load",
"heating",
consume,
requires=[spec("solar.power")],
provides=[spec("used")],
)
pipeline = Pipeline(nodes=[producer, consumer])
assert pipeline.dependencies[consumer] == frozenset({producer})
producer.inject()
assert pipeline.state["heating.used"] == 500.0
def test_ports_keep_function_arguments_local():
def convert(celsius, params):
return {"fahrenheit": celsius * 9 / 5 + 32}
node = make_node(
"convert",
"heating",
convert,
requires=[spec("solar.celsius", port="celsius")],
provides=[spec("fahrenheit")],
)
Pipeline(nodes=[node])
assert node.execute({"solar.celsius": 100.0}) == {"heating.fahrenheit": 212.0}
def test_validate_reports_cycles_and_dangling_inputs():
a = make_node(
"a", "f", lambda b, params: {"a": b}, requires=[spec("b")], provides=[spec("a")]
)
b = make_node(
"b", "f", lambda a, params: {"b": a}, requires=[spec("a")], provides=[spec("b")]
)
lonely = make_node(
"lonely", "f", lambda missing, params: None, requires=[spec("missing")]
)
issues = Pipeline(nodes=[a, b, lonely]).validate()
codes = {issue.code for issue in issues}
assert "cycle" in codes
assert "unconnected_input" in codes
dangling = next(i for i in issues if i.code == "unconnected_input")
assert dangling.message_name == "f.missing"
assert dangling.node == "f.lonely"
def test_declared_flow_inputs_are_not_dangling():
node = make_node(
"n", "f", lambda setpoint, params: None, requires=[spec("setpoint")]
)
issues = Pipeline(nodes=[node]).validate({"f.setpoint": True})
assert issues == []
def test_a_flow_input_without_a_starting_value_is_reported():
node = make_node(
"n", "f", lambda setpoint, params: None, requires=[spec("setpoint")]
)
issues = Pipeline(nodes=[node]).validate({"f.setpoint": False})
assert [issue.code for issue in issues] == ["missing_initial_value"]
def test_a_batch_flows_input_is_a_run_parameter_not_a_missing_value():
"""A flow that only runs when asked gets its inputs from the run.
Calling that a message nothing ever sets had the health summary count a
training flow as one that cannot run while the Runs screen showed it
running. A live flow, which nothing is going to start on its own, still
reports it.
"""
node = make_node(
"n", "f", lambda setpoint, params: None, requires=[spec("setpoint")]
)
batch = FlowDef(name="f", mode="batch", inputs=[FlowInput(spec=spec("setpoint"))])
live = batch.model_copy(update={"mode": "live"})
assert Pipeline(nodes=[node]).validate(_declared_inputs(batch)[0]) == []
issues = Pipeline(nodes=[node]).validate(_declared_inputs(live)[0])
assert [issue.code for issue in issues] == ["missing_initial_value"]
def test_a_failing_node_does_not_stop_its_siblings():
ran = []
def boom(params):
raise RuntimeError("nope")
def fine(params):
ran.append("fine")
return {"ok": 1.0}
bad = make_node("bad", "f", boom, provides=[spec("bad_out")])
good = make_node("good", "f", fine, provides=[spec("ok")])
pipeline = Pipeline(nodes=[bad, good])
pipeline.run()
assert ran == ["fine"]
assert pipeline.state["f.ok"] == 1.0
def test_a_node_returning_something_other_than_a_dict_says_what_is_wrong():
"""Outputs are keyed by port, so a bare value cannot be one of them."""
events = []
def wrong(params):
return 42.0
bus = EventBus()
bus.publish = events.append # type: ignore[method-assign]
node = make_node("n", "f", wrong, provides=[spec("out")])
Pipeline(nodes=[node], events=bus).run()
(error,) = [e for e in events if e["type"] == "node_error"]
assert "NodeOutputError" in error["error"]
assert "returned float" in error["error"]
def test_values_carry_timestamps():
node = make_node("n", "f", lambda params: {"out": 1.0}, provides=[spec("out")])
pipeline = Pipeline(nodes=[node])
node.inject()
values = pipeline.values("f")
assert values["f.out"]["value"] == 1.0
assert values["f.out"]["ts"] > 0
def test_only_advisory_codes_are_flagged_advisory():
"""The UI reads this to keep an advisory out of the fault tone."""
hook = ValidationIssue(code="unauthenticated_hook", message="open")
cycle = ValidationIssue(code="cycle", message="loop")
assert hook.advisory is True
assert cycle.advisory is False
def test_a_producer_that_cannot_run_does_not_strand_its_consumer():
"""One wave, two producers of one consumer, and one of them never ready.
The house's shape: a battery reading wakes the boiler arbiter and the
watering-pump trigger in the same wave, and the DMX encoder reads both. The
pump waits on a pulse that only exists at 02:00, so it is never ready — and
passing over it used to leave the encoder unreachable, which put every
boiler and plug command into its message and no further.
"""
ran = []
power = make_node(
"cerbo", "power", lambda params: {"batt_v": 50.3}, provides=[spec("batt_v")]
)
def arbitrate(batt_v, params):
ran.append("boiler")
return {"boiler_on": 1.0}
boiler = make_node(
"arbiter",
"boiler",
arbitrate,
requires=[spec("power.batt_v")],
provides=[spec("boiler_on")],
)
# Woken by the same wave; `pump_for` has never been published.
pump = make_node(
"pump_run",
"plugs",
lambda batt_v, pump_for, params: {"pump": 1.0},
requires=[spec("power.batt_v"), spec("plugs.pump_for")],
provides=[spec("pump")],
)
def encode(boiler_on, pump, params):
ran.append("encoder")
return {"dmx_boiler": boiler_on}
encoder = make_node(
"switches",
"dmx",
encode,
requires=[spec("boiler.boiler_on"), spec("plugs.pump")],
provides=[spec("dmx_boiler")],
)
pipeline = Pipeline(nodes=[power, boiler, pump, encoder])
pipeline.state["plugs.pump"] = 0.0
power.inject()
assert ran == ["boiler", "encoder"]
assert pipeline.state["dmx.dmx_boiler"] == 1.0
def test_a_quiet_producer_does_not_veto_a_noisy_sibling():
"""Two report-by-exception producers, one consumer, and only one with news.
The house reads both boilers into one DMX encoder through `rbe` nodes. The
kitchen boiler is unchanged almost always, so it returns nothing — and
while that held the encoder's dependency count up, the main boiler's
command reached its message and got no further.
"""
ran = []
power = make_node(
"cerbo", "power", lambda params: {"batt_v": 50.3}, provides=[spec("batt_v")]
)
water = make_node(
"water_changed",
"boiler",
lambda batt_v, params: {"water_boiler": 1.0},
requires=[spec("power.batt_v")],
provides=[spec("water_boiler")],
)
# Unchanged, so its rbe publishes nothing this wave.
kitchen = make_node(
"kitchen_changed",
"boiler",
lambda batt_v, params: None,
requires=[spec("power.batt_v")],
provides=[spec("kitchen_boiler")],
)
def encode(water_boiler, kitchen_boiler, params):
ran.append("switches")
return {"dmx_water_boiler": 255.0 if water_boiler else 0.0}
encoder = make_node(
"switches",
"dmx",
encode,
requires=[spec("boiler.water_boiler"), spec("boiler.kitchen_boiler")],
provides=[spec("dmx_water_boiler")],
)
pipeline = Pipeline(nodes=[power, water, kitchen, encoder])
pipeline.state["boiler.water_boiler"] = 0.0
pipeline.state["boiler.kitchen_boiler"] = 0.0
power.inject()
assert ran == ["switches"]
assert pipeline.state["dmx.dmx_water_boiler"] == 255.0
def test_a_consumer_of_only_quiet_producers_still_does_not_run():
"""The other half of it: freeing a consumer is not the same as running it.
Nothing it reads was refreshed, so it must stay put — otherwise every wave
would re-run the whole graph on values it has already seen.
"""
ran = []
power = make_node(
"cerbo", "power", lambda params: {"batt_v": 50.3}, provides=[spec("batt_v")]
)
quiet = make_node(
"changed",
"boiler",
lambda batt_v, params: None,
requires=[spec("power.batt_v")],
provides=[spec("water_boiler")],
)
def encode(water_boiler, params):
ran.append("switches")
return {"dmx_water_boiler": 255.0}
encoder = make_node(
"switches",
"dmx",
encode,
requires=[spec("boiler.water_boiler")],
provides=[spec("dmx_water_boiler")],
)
pipeline = Pipeline(nodes=[power, quiet, encoder])
pipeline.state["boiler.water_boiler"] = 0.0
power.inject()
assert ran == []