diff --git a/cmake/flags.cmake b/cmake/flags.cmake index c307907..1d61f48 100644 --- a/cmake/flags.cmake +++ b/cmake/flags.cmake @@ -1,4 +1,10 @@ -set(CMAKE_CXX_STANDARD 20) +# Honor a standard supplied by the toolchain (e.g. a consumer's Conan +# cppstd); default to 20 only when none was set, so the package_id's +# cppstd never disagrees with the objects actually built. Piper's own +# code requires >= 20. +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 20) +endif() set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) diff --git a/conan/all/conanfile.py b/conan/all/conanfile.py index 28ff851..5b949f4 100644 --- a/conan/all/conanfile.py +++ b/conan/all/conanfile.py @@ -50,6 +50,14 @@ def configure(self): def layout(self): cmake_layout(self) + def package_id(self): + # The packaged public headers contain zero standard-conditional code + # (no __cplusplus / feature-test-macro branches), so one binary serves + # any consumer cppstd at or above the validate() floor. Drop cppstd + # from the id so a single binary is produced instead of Conan's + # compatibility fallback silently serving a lower-std binary. + del self.info.settings.compiler.cppstd + def validate(self): if self.settings.compiler.get_safe("cppstd"): check_min_cppstd(self, 20) diff --git a/engine/include/piper/engine/step.h b/engine/include/piper/engine/step.h index 8013deb..b5b9358 100644 --- a/engine/include/piper/engine/step.h +++ b/engine/include/piper/engine/step.h @@ -249,15 +249,17 @@ namespace piper::engine output(name) = value; } - // Engine calls this exactly once per Step instance, just - // before declare_io(). A second call -- or any user-code call - // -- throws std::logic_error. + // Engine binds the step to its per-engine IoBlock: once at + // build() (just before declare_io()) and again before every + // compute() call at tick time. The per-tick rebind is what lets + // ONE live step instance serve several engines -- e.g. a + // hardware-singleton device step shared by two pipelines: each + // engine wires its own IoBlock, and input()/output() resolve + // against whichever engine is currently ticking. Engines are + // single-threaded and tick sequentially, so this is race-free; + // the cost is one pointer assignment per step per stage. void init(IoBlock& block) { - if (io_ != nullptr) - { - throw std::logic_error("Step::init: already initialized"); - } io_ = █ } diff --git a/engine/src/engine.cc b/engine/src/engine.cc index 9e200db..602de06 100644 --- a/engine/src/engine.cc +++ b/engine/src/engine.cc @@ -323,6 +323,10 @@ namespace piper::engine make_build_diagnostic(BuildDiagnostic::Kind::UnknownStageOnPin, "stage '" + s + "' is not declared on the graph", node.id, attr_name)); + // An undeclared pin stage silently zeroes pin_active for + // every stage, dropping the edge from all ordering graphs + // and disarming cycle detection -- fail the build instead. + has_error = true; return; } active.insert(static_cast(idx)); @@ -375,6 +379,61 @@ namespace piper::engine } // ---- Per-stage topo sort (Kahn) ---- + // A link orders a stage only when BOTH of its pins are active + // there -- a pin with explicit stages is active on those, else it + // inherits its node's home stage. Filtering by node membership + // alone would drag e.g. a write-staged back-link between two + // read+write nodes into the read stage and report a false cycle + // (see tests/engine/cross_stage_link-t.cc). + std::map>> pin_stages; + for (auto const& node : graph.nodes()) + { + auto const home = stage_index_of(node.stage); + for (auto const& attr : node.attrs) + { + if (attr.role != piper::AttributeSpec::Role::Input + and attr.role != piper::AttributeSpec::Role::Output) + { + continue; + } + std::vector active_on; + if (attr.stages.empty()) + { + if (home < stage_data_.size()) + { + active_on.push_back(static_cast(home)); + } + } + else + { + for (auto const& sname : attr.stages) + { + auto const idx = stage_index_of(sname); + if (idx < stage_data_.size()) + { + active_on.push_back(static_cast(idx)); + } + } + } + std::sort(active_on.begin(), active_on.end()); + pin_stages[node.id][attr.name] = std::move(active_on); + } + } + auto pin_active = [&pin_stages](piper::NodeId id, std::string const& attr, uint16_t s) + { + auto nit = pin_stages.find(id); + if (nit == pin_stages.end()) + { + return false; + } + auto ait = nit->second.find(attr); + if (ait == nit->second.end()) + { + return false; + } + return std::binary_search(ait->second.begin(), ait->second.end(), s); + }; + // Use ordered containers so the resulting tick order is // deterministic across runs and platforms. for (uint16_t s = 0; s < stage_data_.size(); ++s) @@ -402,6 +461,11 @@ namespace piper::engine { continue; } + if (not pin_active(link.from.node, link.from.attr, s) + or not pin_active(link.to.node, link.to.attr, s)) + { + continue; + } succ[link.from.node].push_back(link.to.node); ++in_degree[link.to.node]; } @@ -546,6 +610,10 @@ namespace piper::engine { continue; } + // Rebind before compute: a step instance may be shared by + // several engines (hardware singletons); input()/output() + // must resolve against THIS engine's wiring. + bit->second.step->init(bit->second); bit->second.step->compute(stage); } } diff --git a/tests/engine/CMakeLists.txt b/tests/engine/CMakeLists.txt index bbd77fc..3446c87 100644 --- a/tests/engine/CMakeLists.txt +++ b/tests/engine/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(piper_engine_test build_smoke-t.cc + cross_stage_link-t.cc cycle-t.cc declare_io_failure-t.cc external_io-t.cc @@ -8,6 +9,9 @@ add_executable(piper_engine_test mode_gating-t.cc motor_control_smoke-t.cc multi_stage_tick-t.cc + optional_input-t.cc + pin_stage_typo_cycle-t.cc + shared_step-t.cc single_stage_tick-t.cc ) target_link_libraries(piper_engine_test PRIVATE diff --git a/tests/engine/cross_stage_link-t.cc b/tests/engine/cross_stage_link-t.cc new file mode 100644 index 0000000..b7119e6 --- /dev/null +++ b/tests/engine/cross_stage_link-t.cc @@ -0,0 +1,171 @@ +#include + +#include "piper/attribute.h" +#include "piper/graph.h" +#include "piper/node_type.h" +#include "piper/stage.h" + +#include "piper/engine/builtin_steps.h" +#include "piper/engine/engine.h" +#include "piper/engine/registry.h" +#include "piper/engine/step.h" + +using piper::AttributeSpec; +using piper::Graph; +using piper::NodeType; +using piper::PinRef; +using piper::Point; +using piper::engine::Engine; +using piper::engine::hash_name; +using piper::engine::Step; +using piper::engine::StepRegistry; + +namespace piper_engine_test +{ + // A device-style step: publishes a measurement in "read" and consumes a + // command in "write" (per-pin stages) -- the Bus pattern from + // docs/v2_format.md, one physical device spanning two stages. + class MotorStep final : public Step + { + public: + static constexpr auto READ = hash_name("read"); + static constexpr auto WRITE = hash_name("write"); + + void declare_io() override + { + declare_output("position", position_); + declare_input("torque_cmd"); + } + + void compute(piper::engine::Stage current) override + { + if (current.id == READ) + { + position_ = ++revs_; + } + else if (current.id == WRITE) + { + applied_ = input("torque_cmd"); + } + } + + double applied() const { return applied_; } + + private: + double position_{0.0}; + double revs_{0.0}; + double applied_{0.0}; + }; + + // A transform-style step: forwards the measurement in "read", emits the + // command in "control". Its torque output feeds BACK into the motor -- + // the bidirectional cross-stage pair. + class TransformStep final : public Step + { + public: + static constexpr auto READ = hash_name("read"); + static constexpr auto CONTROL = hash_name("control"); + + void declare_io() override + { + declare_input("position_in"); + declare_output("torque_out", torque_); + } + + void compute(piper::engine::Stage current) override + { + if (current.id == READ) + { + seen_at_read_ = input("position_in"); + } + else if (current.id == CONTROL) + { + torque_ = seen_at_read_ * 2.0; + } + } + + double seen_at_read() const { return seen_at_read_; } + + private: + double torque_{0.0}; + double seen_at_read_{0.0}; + }; + + NodeType make_motor_meta() + { + NodeType nt; + nt.type = "test_motor"; + nt.category = "test"; + nt.attributes = { + { "position", "double", AttributeSpec::Role::Output, "" }, + { "torque_cmd", "double", AttributeSpec::Role::Input, "" }, + }; + return nt; + } + + NodeType make_transform_meta() + { + NodeType nt; + nt.type = "test_transform"; + nt.category = "test"; + nt.attributes = { + { "position_in", "double", AttributeSpec::Role::Input, "" }, + { "torque_out", "double", AttributeSpec::Role::Output, "" }, + }; + return nt; + } +} + +// motor.position[read] -> transform.position_in, and +// transform.torque_out[control] -> motor.torque_cmd[write]. +// Both nodes are active in "read", but the back-link's pins are not: it must +// order nothing there. Filtering links by node membership alone drags the +// back-link into "read" and reports a false cycle. +TEST(EngineBuild, CrossStageBackLinkIsNotACycle) +{ + Graph g; + g.add_stage(piper::Stage{ "read", 0xFFFFFFFFu }); + g.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + g.add_stage(piper::Stage{ "write", 0xFFFFFFFFu }); + + // Transform first: with the forward link filtered away too, NodeId + // tie-breaking would run it before the motor in "read" and it would see + // a stale position -- so this order also guards the forward edge. + auto transform_id = g.add_node(piper_engine_test::make_transform_meta(), + "transform", "read", Point{ 1.0f, 0.0f }); + auto motor_id = g.add_node(piper_engine_test::make_motor_meta(), + "motor", "read", Point{ 0.0f, 0.0f }); + + ASSERT_TRUE(g.set_attr_stages(motor_id, "torque_cmd", + std::vector{ "write" })); + ASSERT_TRUE(g.set_attr_stages(transform_id, "torque_out", + std::vector{ "control" })); + + g.add_link(PinRef{ motor_id, "position" }, PinRef{ transform_id, "position_in" }, "double"); + g.add_link(PinRef{ transform_id, "torque_out" }, PinRef{ motor_id, "torque_cmd" }, "double"); + + StepRegistry sr; + piper::engine::register_builtin_steps(sr); + sr.add("test_motor", [] { return std::make_shared(); }); + sr.add("test_transform", [] { return std::make_shared(); }); + + Engine e; + auto res = e.build(g, sr); + ASSERT_TRUE(res.ok) << (res.diagnostics.empty() ? "" : res.diagnostics.front().message); + + auto* motor = dynamic_cast(e.step_for(motor_id)); + auto* transform = dynamic_cast(e.step_for(transform_id)); + ASSERT_NE(motor, nullptr); + ASSERT_NE(transform, nullptr); + + for (int i = 1; i <= 3; ++i) + { + e.tick("read"); + // Forward link still orders "read": the transform sees THIS tick's + // measurement, not last tick's. + EXPECT_EQ(transform->seen_at_read(), static_cast(i)); + e.tick("control"); + e.tick("write"); + EXPECT_EQ(motor->applied(), 2.0 * i); + } +} diff --git a/tests/engine/optional_input-t.cc b/tests/engine/optional_input-t.cc new file mode 100644 index 0000000..68e776f --- /dev/null +++ b/tests/engine/optional_input-t.cc @@ -0,0 +1,149 @@ +#include + +#include + +#include "piper/attribute.h" +#include "piper/graph.h" +#include "piper/node_type.h" +#include "piper/stage.h" + +#include "piper/engine/engine.h" +#include "piper/engine/registry.h" +#include "piper/engine/step.h" + +using piper::AttributeSpec; +using piper::Graph; +using piper::NodeType; +using piper::PinRef; +using piper::Point; +using piper::engine::BuildDiagnostic; +using piper::engine::Engine; +using piper::engine::Step; +using piper::engine::StepRegistry; + +namespace piper_engine_test +{ + // Reads an OPTIONAL "command" input, falling back to a member when + // the pin is unwired -- the read-only / monitor-pipeline pattern the + // downstream project relies on. build() must NOT flag the unwired + // optional pin, and compute() must not throw because it guards the + // read with has_input(). + class OptionalCommandStep final : public Step + { + public: + void declare_io() override + { + declare_output("out", out_); + declare_input("command", /*optional=*/true); + } + + void compute(piper::engine::Stage) override + { + if (has_input("command")) + { + out_ = input("command"); + } + else + { + out_ = fallback_; + } + } + + double out() const { return out_; } + + private: + double out_{0.0}; + double fallback_{-1.0}; + }; + + // Same shape but the "command" input is REQUIRED (declared without the + // optional flag). An unwired required input must fail the build. + class RequiredCommandStep final : public Step + { + public: + void declare_io() override + { + declare_output("out", out_); + declare_input("command"); + } + + void compute(piper::engine::Stage) override + { + out_ = input("command"); + } + + private: + double out_{0.0}; + }; + + NodeType command_meta(char const* type, bool optional) + { + NodeType nt; + nt.type = type; + nt.category = "test"; + nt.attributes = { + { "out", "double", AttributeSpec::Role::Output, "" }, + { "command", "double", AttributeSpec::Role::Input, "", false, optional }, + }; + return nt; + } +} + +// An optional command input left unwired builds successfully, and the step +// reads it safely through has_input() (falling back to its member). +TEST(EngineOptionalInput, OptionalUnwiredInputBuildsAndReadsSafely) +{ + using namespace piper_engine_test; + + auto step = std::make_shared(); + + StepRegistry sr; + sr.add("optional_command", [step] { return step; }); + + Graph g; + g.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + auto id = g.add_node(command_meta("optional_command", true), "dev", "control", Point{ 0.f, 0.f }); + (void)id; + + Engine e; + auto res = e.build(g, sr); + + ASSERT_TRUE(res.ok) << (res.diagnostics.empty() + ? std::string{"no diagnostics"} + : res.diagnostics.front().message); + EXPECT_TRUE(res.diagnostics.empty()); + + // compute() must not throw on the unwired optional pin; it falls back. + e.play(); + EXPECT_DOUBLE_EQ(step->out(), -1.0); +} + +// The sibling case: a required (non-optional) input left unwired fails the +// build with an UnresolvedInput diagnostic naming the pin. +TEST(EngineOptionalInput, RequiredUnwiredInputFailsBuild) +{ + using namespace piper_engine_test; + + auto step = std::make_shared(); + + StepRegistry sr; + sr.add("required_command", [step] { return step; }); + + Graph g; + g.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + g.add_node(command_meta("required_command", false), "dev", "control", Point{ 0.f, 0.f }); + + Engine e; + auto res = e.build(g, sr); + + EXPECT_FALSE(res.ok); + bool flagged_command = false; + for (auto const& d : res.diagnostics) + { + if (d.kind == BuildDiagnostic::Kind::UnresolvedInput and d.attr_name == "command") + { + flagged_command = true; + } + } + EXPECT_TRUE(flagged_command) << "required 'command' should fail the build"; +} diff --git a/tests/engine/pin_stage_typo_cycle-t.cc b/tests/engine/pin_stage_typo_cycle-t.cc new file mode 100644 index 0000000..984bcb3 --- /dev/null +++ b/tests/engine/pin_stage_typo_cycle-t.cc @@ -0,0 +1,168 @@ +#include + +#include "piper/builtin_nodes.h" +#include "piper/graph.h" +#include "piper/link.h" +#include "piper/registry.h" +#include "piper/stage.h" + +#include "piper/engine/builtin_steps.h" +#include "piper/engine/engine.h" +#include "piper/engine/registry.h" + +using piper::Graph; +using piper::LinkId; +using piper::NodeRegistry; +using piper::PinRef; +using piper::Point; +using piper::engine::BuildDiagnostic; +using piper::engine::Engine; +using piper::engine::StepRegistry; + +namespace +{ + bool has_kind(piper::engine::Engine::BuildResult const& res, + BuildDiagnostic::Kind kind) + { + for (auto const& d : res.diagnostics) + { + if (d.kind == kind) + { + return true; + } + } + return false; + } +} + +// Probe (a): a genuine two-node same-stage cycle whose edge's pin carries a +// TYPO'd stage name. The typo makes pin_active false on every stage, dropping +// the edge from all ordering graphs and (pre-fix) silently hiding the cycle so +// build() returned ok. An undeclared pin stage is a config error -> fail build. +TEST(PinStageTypoCycle, TypoedPinStageBreaksCycleButFailsBuild) +{ + NodeRegistry nr; + piper::register_builtin_nodes(nr); + + Graph g; + g.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + + auto const* lp = nr.find("low_pass"); + auto a_id = g.add_node(*lp, "a", "control", Point{ 0.0f, 0.0f }); + auto b_id = g.add_node(*lp, "b", "control", Point{ 1.0f, 0.0f }); + g.set_attr_value(a_id, "cutoff", "10.0"); + g.set_attr_value(b_id, "cutoff", "10.0"); + + g.add_link(PinRef{ a_id, "out" }, PinRef{ b_id, "in" }, "float"); + g.add_link(PinRef{ b_id, "out" }, PinRef{ a_id, "in" }, "float"); + + // Typo on the back-link's producer pin: "cntrol" is not a declared stage. + ASSERT_TRUE(g.set_attr_stages(b_id, "out", + std::vector{ "cntrol" })); + + StepRegistry sr; + piper::engine::register_builtin_steps(sr); + + Engine e; + auto res = e.build(g, sr); + + EXPECT_FALSE(res.ok); + EXPECT_TRUE(has_kind(res, BuildDiagnostic::Kind::UnknownStageOnPin)); +} + +// Probe (b): a stage is removed AFTER a pin was staged on it. remove_stage does +// not cascade, so the pin's stage list survives pointing at a stage the graph +// no longer owns -- same fail-open primitive as a typo. +TEST(PinStageTypoCycle, StageRemovedUnderStagedPinFailsBuild) +{ + NodeRegistry nr; + piper::register_builtin_nodes(nr); + + Graph g; + g.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + g.add_stage(piper::Stage{ "extra", 0xFFFFFFFFu }); + + auto const* lp = nr.find("low_pass"); + auto a_id = g.add_node(*lp, "a", "control", Point{ 0.0f, 0.0f }); + auto b_id = g.add_node(*lp, "b", "control", Point{ 1.0f, 0.0f }); + g.set_attr_value(a_id, "cutoff", "10.0"); + g.set_attr_value(b_id, "cutoff", "10.0"); + + g.add_link(PinRef{ a_id, "out" }, PinRef{ b_id, "in" }, "float"); + g.add_link(PinRef{ b_id, "out" }, PinRef{ a_id, "in" }, "float"); + + ASSERT_TRUE(g.set_attr_stages(b_id, "out", + std::vector{ "extra" })); + g.remove_stage("extra"); + + StepRegistry sr; + piper::engine::register_builtin_steps(sr); + + Engine e; + auto res = e.build(g, sr); + + EXPECT_FALSE(res.ok); + EXPECT_TRUE(has_kind(res, BuildDiagnostic::Kind::UnknownStageOnPin)); +} + +// Probe (c): a node whose HOME stage is typo'd closes a cycle through its +// default (unstaged) pins. Default pins inherit the home stage, so the typo +// resolves them to nothing and hides the cycle -- the home-stage absorb must +// itself be fatal. +TEST(PinStageTypoCycle, TypoedHomeStageThroughDefaultPinFailsBuild) +{ + NodeRegistry nr; + piper::register_builtin_nodes(nr); + + Graph g; + g.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + + auto const* lp = nr.find("low_pass"); + // "cntrol" is a typo of the only declared stage. + auto a_id = g.add_node(*lp, "a", "cntrol", Point{ 0.0f, 0.0f }); + auto b_id = g.add_node(*lp, "b", "control", Point{ 1.0f, 0.0f }); + g.set_attr_value(a_id, "cutoff", "10.0"); + g.set_attr_value(b_id, "cutoff", "10.0"); + + g.add_link(PinRef{ a_id, "out" }, PinRef{ b_id, "in" }, "float"); + g.add_link(PinRef{ b_id, "out" }, PinRef{ a_id, "in" }, "float"); + + StepRegistry sr; + piper::engine::register_builtin_steps(sr); + + Engine e; + auto res = e.build(g, sr); + + EXPECT_FALSE(res.ok); + EXPECT_TRUE(has_kind(res, BuildDiagnostic::Kind::UnknownStageOnPin)); +} + +// Guard: with CORRECT stage names the same two-node same-stage cycle must still +// be caught as a real cycle, not masked by the fatal-typo change. +TEST(PinStageTypoCycle, CorrectStageNamesStillDetectCycle) +{ + NodeRegistry nr; + piper::register_builtin_nodes(nr); + + Graph g; + g.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + + auto const* lp = nr.find("low_pass"); + auto a_id = g.add_node(*lp, "a", "control", Point{ 0.0f, 0.0f }); + auto b_id = g.add_node(*lp, "b", "control", Point{ 1.0f, 0.0f }); + g.set_attr_value(a_id, "cutoff", "10.0"); + g.set_attr_value(b_id, "cutoff", "10.0"); + + g.add_link(PinRef{ a_id, "out" }, PinRef{ b_id, "in" }, "float"); + g.add_link(PinRef{ b_id, "out" }, PinRef{ a_id, "in" }, "float"); + + StepRegistry sr; + piper::engine::register_builtin_steps(sr); + + Engine e; + auto res = e.build(g, sr); + + EXPECT_FALSE(res.ok); + EXPECT_TRUE(has_kind(res, BuildDiagnostic::Kind::CycleDetected)); + EXPECT_FALSE(has_kind(res, BuildDiagnostic::Kind::UnknownStageOnPin)); +} diff --git a/tests/engine/shared_step-t.cc b/tests/engine/shared_step-t.cc new file mode 100644 index 0000000..c9704b8 --- /dev/null +++ b/tests/engine/shared_step-t.cc @@ -0,0 +1,184 @@ +#include + +#include + +#include "piper/attribute.h" +#include "piper/graph.h" +#include "piper/node_type.h" +#include "piper/stage.h" + +#include "piper/engine/engine.h" +#include "piper/engine/registry.h" +#include "piper/engine/step.h" + +using piper::AttributeSpec; +using piper::Graph; +using piper::NodeType; +using piper::PinRef; +using piper::Point; +using piper::engine::Engine; +using piper::engine::hash_name; +using piper::engine::Step; +using piper::engine::StepRegistry; + +namespace piper_engine_test +{ + // A hardware-singleton-style device step: ONE live instance shared by + // several engines (e.g. a drive slave commanded by its joint pipeline + // and observed by a monitor pipeline). The registry factory returns + // the same instance every time -- recreating hardware is conceptually + // wrong. This only works because the engine rebinds the step's + // IoBlock before every compute(): each engine keeps its own wiring, + // while caller-owned output slots make the state shared. + class SharedDeviceStep final : public Step + { + public: + static constexpr auto READ = hash_name("read"); + static constexpr auto WRITE = hash_name("write"); + + void declare_io() override + { + declare_output("measured", measured_); + // Optional: the monitor pipeline wires only "measured" and + // leaves "command" unwired; build() must not flag it. + declare_input("command", /*optional=*/true); + } + + void compute(piper::engine::Stage current) override + { + if (current.id == READ) + { + measured_ = state_; + } + else if (current.id == WRITE and has_input("command")) + { + state_ = input("command"); + } + } + + double state() const { return state_; } + + private: + double state_{0.0}; + double measured_{0.0}; + }; + + class SourceStep final : public Step + { + public: + void declare_io() override { declare_output("out", value_); } + void compute(piper::engine::Stage) override {} + void set(double v) { value_ = v; } + + private: + double value_{0.0}; + }; + + class SinkStep final : public Step + { + public: + void declare_io() override { declare_input("in"); } + void compute(piper::engine::Stage) override { last_ = input("in"); } + double last() const { return last_; } + + private: + double last_{0.0}; + }; + + NodeType device_meta() + { + NodeType nt; + nt.type = "shared_device"; + nt.category = "test"; + nt.attributes = { + { "measured", "double", AttributeSpec::Role::Output, "" }, + { "command", "double", AttributeSpec::Role::Input, "", false, true }, + }; + return nt; + } + + NodeType source_meta() + { + NodeType nt; + nt.type = "test_source"; + nt.category = "test"; + nt.attributes = { { "out", "double", AttributeSpec::Role::Output, "" } }; + return nt; + } + + NodeType sink_meta() + { + NodeType nt; + nt.type = "test_sink"; + nt.category = "test"; + nt.attributes = { { "in", "double", AttributeSpec::Role::Input, "" } }; + return nt; + } +} + +// One live device instance in TWO engines: a control pipeline commands it, +// a monitor pipeline observes it. Both builds must succeed (the old +// init-throws-once guard forbade this), both wirings must resolve against +// the ticking engine, and the state must be shared through the single +// instance. +TEST(EngineSharedStep, OneLiveInstanceServesTwoEngines) +{ + using namespace piper_engine_test; + + auto device = std::make_shared(); + + StepRegistry sr; + sr.add("shared_device", [device] { return device; }); + sr.add("test_source", [] { return std::make_shared(); }); + sr.add("test_sink", [] { return std::make_shared(); }); + + // Control pipeline: source(control) -> device.command[write]. + Graph control; + control.add_stage(piper::Stage{ "read", 0xFFFFFFFFu }); + control.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + control.add_stage(piper::Stage{ "write", 0xFFFFFFFFu }); + auto src_id = control.add_node(source_meta(), "source", "control", Point{ 0.f, 0.f }); + auto dev_a = control.add_node(device_meta(), "device", "read", Point{ 1.f, 0.f }); + ASSERT_TRUE(control.set_attr_stages(dev_a, "command", { "write" })); + control.add_link(PinRef{ src_id, "out" }, PinRef{ dev_a, "command" }, "double"); + + // Monitor pipeline: device.measured[read] -> sink(control). + Graph monitor; + monitor.add_stage(piper::Stage{ "read", 0xFFFFFFFFu }); + monitor.add_stage(piper::Stage{ "control", 0xFFFFFFFFu }); + auto dev_b = monitor.add_node(device_meta(), "device", "read", Point{ 0.f, 1.f }); + auto snk_id = monitor.add_node(sink_meta(), "sink", "control", Point{ 1.f, 1.f }); + monitor.add_link(PinRef{ dev_b, "measured" }, PinRef{ snk_id, "in" }, "double"); + + Engine control_engine; + auto res_a = control_engine.build(control, sr); + ASSERT_TRUE(res_a.ok) << (res_a.diagnostics.empty() ? "" : res_a.diagnostics.front().message); + + Engine monitor_engine; + auto res_b = monitor_engine.build(monitor, sr); + ASSERT_TRUE(res_b.ok) << (res_b.diagnostics.empty() ? "" : res_b.diagnostics.front().message); + + // Singleton proof: both engines hold THE instance. + EXPECT_EQ(control_engine.step_for(dev_a), device.get()); + EXPECT_EQ(monitor_engine.step_for(dev_b), device.get()); + + auto* source = dynamic_cast(control_engine.step_for(src_id)); + auto* sink = dynamic_cast(monitor_engine.step_for(snk_id)); + ASSERT_NE(source, nullptr); + ASSERT_NE(sink, nullptr); + + // Cycle 1: command 3.5 through the control pipeline, observe it in + // the monitor pipeline on the next read. + source->set(3.5); + control_engine.play(); // write: device.state <- 3.5 + EXPECT_DOUBLE_EQ(device->state(), 3.5); // control wiring resolved (post-B build!) + monitor_engine.play(); // read: measured <- state; sink sees it + EXPECT_DOUBLE_EQ(sink->last(), 3.5); // monitor wiring resolved + + // Cycle 2: interleaved ticks keep resolving against the right engine. + source->set(-1.25); + control_engine.play(); + monitor_engine.play(); + EXPECT_DOUBLE_EQ(device->state(), -1.25); + EXPECT_DOUBLE_EQ(sink->last(), -1.25); +}