"""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 == []