diff --git a/CHANGELOG.md b/CHANGELOG.md index 889681b..6c5420f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 thread-safe blackboard for side-channel data between stages (`set` / `get` / `getOr` / `contains` / `erase` / `update` / `clear`). It is a polymorphic base; derived contexts are recovered with `as()`. +- **`TypedMergeFilter`** / + **`UniformMergeFilter`** / + **`registerTypedMergeFilter`** (`MergeFilter.hpp`) — merge stages that declare + their slot types. The DSL checks the edges of their fan-in group when the + graph is built (`slot 2 of 'Summarize' expects ... but edge 'msg' carries ...`, + `stage 'Summarize' takes 2 inputs but the group has 3`), so a mis-wired merge + no longer fails with a `std::bad_any_cast` on the first message. The stages + receive their slots as typed `std::optional`s (`std::nullopt` for a hole), + with no `any_cast` in user code. `MergeFilter` / `registerMergeFilter` declare + no slot types and keep their current, unchecked behaviour. +- **`MergeStage` / `MergeSlotTypes`** (`MergeStage.hpp`, new header) — how a + merge stage declares its slot types; `AnyMessageFilter::mergeInputTypes()` + exposes them to the DSL. `MergeInputs` moved here from `MergeFilter.hpp` + (which still provides it). - **`MessageFilter::setContext` / `context()` / `sharedContext()`** — stages receive the graph's `GraphContext`. `FilterGraph`, `DslFilterGraph`, `JsonFilterGraph`, `AnyFilterChain`, `FanoutFilter` and `JoinFilter` create an diff --git a/EXAMPLE.md b/EXAMPLE.md index beedf44..5a259f6 100644 --- a/EXAMPLE.md +++ b/EXAMPLE.md @@ -217,6 +217,56 @@ The `MinLength=100` path drops the 19-character input, so its slot is a the missing path. A merge is skipped only when *every* one of its edges is empty — then there is nothing to combine, and the drop propagates. +### Typed slots + +`Concat` takes its slots untyped: nothing checks that the group really carries +three strings, and swapping two edges of the same type would pass validation +and quietly produce a different result. A merge that declares its slot types +avoids both. `TypedMergeFilter` fixes the number and type of the +slots and hands them over as `std::optional`s: + +```cpp +class ReportFilter : public TypedMergeFilter +{ +public: + std::optional merge(std::optional&& upper, + std::optional&& length) override + { + return std::format("{} ({} chars)", upper.value_or("-"), length.value_or(0)); + } +}; +static FilterRegistrar registerReport("Report"); +``` + +```cpp +DslFilterGraph pipeline(R"dsl( + in -> Uppercase -> upper + in -> Length -> length + (upper, length) -> Report -> reported -> Print(prefix="[typed] ") -> out +)dsl"); + +pipeline.filter(std::string{"Hello, filterGraph!"}); +``` + +```text +[typed] HELLO, FILTERGRAPH! (19 chars) +``` + +Writing the group the other way round is now rejected when the graph is built, +instead of throwing `std::bad_any_cast` on the first message: + +```text +3:20: slot 1 of 'Report' expects 'class std::basic_string' but edge 'length' carries 'unsigned __int64' +3:20: slot 2 of 'Report' expects 'unsigned __int64' but edge 'upper' carries 'class std::basic_string' +``` + +(The type names come from `typeid(...).name()`, so their spelling depends on the +compiler; they are shortened here.) + +`registerTypedMergeFilter(name, lambda)` does the same from a +lambda, and `UniformMergeFilter` covers the other shape: any number of +slots, all of the same type (N variants of one computation, combined). + ## 5. Several named outputs A graph can have more than one output. `out.` names each one, and the @@ -312,7 +362,9 @@ What gets checked: both types (type names come from `typeid`, so their spelling depends on the compiler). - **Fan-in and `Void`** — groups must feed merge stages, merge stages need a - group, and a stage producing `Void` must route to `end`. + group, and a stage producing `Void` must route to `end`. A merge that + declares its slot types (`TypedMergeFilter`, `UniformMergeFilter`) also has + the number and type of its group's edges checked. - **Outputs** — the number and type of outputs must fit the graph's `OutputType`. diff --git a/README.md b/README.md index 384b8cc..05c99cb 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ drops the message, and everything downstream of that edge is skipped. - **`MergeFilter`** / **`registerMergeFilter`** — fan-in stages: a C++ combiner turns the values of several edges into one, seeing an empty slot ("hole") for every edge whose path dropped the message. +- **`TypedMergeFilter`** / **`UniformMergeFilter`** / + **`registerTypedMergeFilter`** — fan-in stages that declare their slot types, + so the edges of a group are checked when the graph is built and the stage + receives typed `std::optional`s instead of `std::any`. - **`validateDslGraph(text)`** — the same checks as construction, returned as a list of `line:column` diagnostics instead of a thrown **`GraphError`**. @@ -241,9 +245,10 @@ edge -> Stage -> edge -> Stage(key=value) -> edge - **Fan-out:** read the same edge in several statements. Every reader gets its own copy of the value. - **Fan-in:** `(a, b, c) -> Merge -> merged` gathers several edges into a merge - stage registered with `registerMergeFilter(name, combiner)`. The - combiner receives `MergeInputs` (`std::vector`), one slot per edge, - in the order listed. + stage, one slot per edge, in the order listed. A merge either declares its + slot types (see [Typed merges](#typed-merges)) or takes them untyped as + `MergeInputs` (`std::vector`), as + `registerMergeFilter(name, combiner)` does. - `#` starts a comment that runs to the end of the line. ### How a graph runs @@ -260,6 +265,48 @@ For every message: skipped only when *all* of its edges are empty. 5. Values routed to `end` are discarded. +### Typed merges + +A merge stage may declare the type of each of its slots. The DSL then checks +the edges of its group when the graph is built, like every other edge, instead +of failing with a `std::bad_any_cast` on the first message — and the stage +receives typed `std::optional`s, so it needs no `any_cast` of its own: + +```cpp +// Fixed arity, one type per slot. +class Summarize : public TypedMergeFilter +{ +public: + std::optional merge(std::optional&& message, std::optional&& valid) override + { + return Stats{...}; // an empty optional is a hole: that path dropped the message + } +}; +static FilterRegistrar registerSummarize("Summarize"); + +// The same from a lambda, without a subclass. +registerTypedMergeFilter( + "Summarize", + [](std::optional&& message, std::optional&& valid) -> std::optional { ... }); + +// Any number of slots, all of one type: N variants of the same computation. +class PickBest : public UniformMergeFilter +{ +public: + std::optional merge(std::vector>&& candidates) override { ... } +}; +``` + +Mis-wiring a group is then a build-time diagnostic, not a wrong result: + +```text +4:34: slot 2 of 'Summarize' expects 'struct Valid' but edge 'msg' carries 'struct Message' +4:34: stage 'Summarize' takes 2 inputs but the group has 3 +``` + +`MergeFilter` / `registerMergeFilter` declare no slot types; their edges stay +unchecked, and the combiner reads the slots with `std::any_cast`. + ### Choosing the output type `DslFilterGraph`'s `OutputType` states what the graph's diff --git a/apps/textPipeline/main.cpp b/apps/textPipeline/main.cpp index 5a07de1..2c908ed 100644 --- a/apps/textPipeline/main.cpp +++ b/apps/textPipeline/main.cpp @@ -31,6 +31,7 @@ using filterGraph::FilterRegistrar; using filterGraph::JsonFilterGraph; using filterGraph::MergeInputs; using filterGraph::MessageFilter; +using filterGraph::TypedMergeFilter; using filterGraph::registerFanoutFilter; using filterGraph::registerMergeFilter; using filterGraph::validateDslGraph; @@ -157,6 +158,21 @@ static const bool sRegisterConcat = [] { return true; }(); +// A typed merge stage for `(upper, length) -> Report`: it declares the type of +// each slot, so the DSL checks the group's edges when the graph is built, and +// the slots arrive as std::optionals (empty = a hole) instead of std::any. +class ReportFilter : public TypedMergeFilter +{ +public: + std::optional merge(std::optional&& upper, + std::optional&& length) override + { + return std::format("{} ({} chars)", upper.value_or("-"), length.value_or(0)); + } +}; + +static FilterRegistrar registerReport("Report"); + // Only needed by the JSON example: in the DSL, fan-out is built in. static const bool sRegisterFanout = [] { registerFanoutFilter("Fanout"); @@ -203,6 +219,27 @@ int main() pipeline.filter(std::string{"Hello, filterGraph!"}); } + // 3b) The same fan-in with a typed merge: Report declares its slot types, + // so a group in the wrong order is rejected when the graph is built + // instead of throwing std::bad_any_cast on the first message. + { + DslFilterGraph pipeline(R"dsl( + in -> Uppercase -> upper + in -> Length -> length + (upper, length) -> Report -> reported -> Print(prefix="[typed] ") -> out + )dsl"); + + pipeline.filter(std::string{"Hello, filterGraph!"}); + + for (const auto& diagnostic : validateDslGraph( + "in -> Uppercase -> upper\n" + "in -> Length -> length\n" + "(length, upper) -> Report -> reported -> Print -> out\n")) + { + std::cout << "[typed check] " << dsl::formatDiagnostic(diagnostic) << '\n'; + } + } + // 4) Several named outputs of different types, returned as GraphOutputs. { DslFilterGraph analysis(R"dsl( diff --git a/libs/filterGraph/CMakeLists.txt b/libs/filterGraph/CMakeLists.txt index 8cb01a4..e850971 100644 --- a/libs/filterGraph/CMakeLists.txt +++ b/libs/filterGraph/CMakeLists.txt @@ -14,6 +14,7 @@ target_sources(${PROJECT_NAME} core/filterGraph/JsonFilterGraph.hpp core/filterGraph/FanoutFilter.hpp core/filterGraph/JoinFilter.hpp + core/filterGraph/MergeStage.hpp core/filterGraph/MergeFilter.hpp core/filterGraph/GraphValidator.hpp core/filterGraph/GraphLang.hpp diff --git a/libs/filterGraph/core/filterGraph/AnyMessageFilter.hpp b/libs/filterGraph/core/filterGraph/AnyMessageFilter.hpp index ec233ac..8556993 100644 --- a/libs/filterGraph/core/filterGraph/AnyMessageFilter.hpp +++ b/libs/filterGraph/core/filterGraph/AnyMessageFilter.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -30,6 +31,13 @@ class AnyMessageFilter // Hands the graph's context to the wrapped stage(s); see // MessageFilter::setContext. virtual void setContext(std::shared_ptr context) = 0; + + // The slot types of a merge stage (see MergeStage); empty for an untyped + // merge and for every stage that is not a merge. + virtual MergeSlotTypes mergeInputTypes() const + { + return {}; + } }; // Adapts a concrete MessageFilter to the @@ -69,6 +77,15 @@ class AnyMessageFilterAdapter : public AnyMessageFilter mFilter->setContext(std::move(context)); } + MergeSlotTypes mergeInputTypes() const override + { + if (const auto* merge = dynamic_cast(mFilter.get())) + { + return merge->mergeInputTypes(); + } + return {}; + } + private: std::shared_ptr mFilter; }; diff --git a/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp b/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp index 7d73bb1..52a86e7 100644 --- a/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp +++ b/libs/filterGraph/core/filterGraph/DslFilterGraph.hpp @@ -229,6 +229,47 @@ inline std::string describeEdge(const std::string& edge) return std::format("edge '{}'", edge); } +// Checks the edges of a fan-in group against the slot types the merge stage +// declares (MergeStage); an untyped merge declares none and is not checked. +inline void checkMergeInputs(const StageNode& node, + const MergeSlotTypes& slots, + const std::unordered_map& edgeTypes, + std::vector& diagnostics) +{ + if (slots.empty()) + { + return; + } + + if (!slots.uniform && slots.types.size() != node.inputs.size()) + { + // Without a slot-to-edge correspondence, per-slot messages would only + // repeat this one. + diagnostics.push_back({node.loc, + std::format("stage '{}' takes {} inputs but the group has {}", + node.type, + slots.types.size(), + node.inputs.size())}); + return; + } + + for (std::size_t slot = 0; slot < node.inputs.size(); ++slot) + { + const std::type_index expected = slots.uniform ? slots.types.front() : slots.types[slot]; + const auto type = edgeTypes.find(node.inputs[slot]); + if (type != edgeTypes.end() && type->second != expected) + { + diagnostics.push_back({node.loc, + std::format("slot {} of '{}' expects '{}' but {} carries '{}'", + slot + 1, + node.type, + expected.name(), + describeEdge(node.inputs[slot]), + type->second.name())}); + } + } +} + struct CompiledStage { std::shared_ptr filter; @@ -309,7 +350,11 @@ class GraphPlan node.type, node.type)}); } - else if (!merge) + else if (merge) + { + checkMergeInputs(node, filter->mergeInputTypes(), edgeTypes, diagnostics); + } + else { const std::string& edge = node.inputs.front(); auto type = edgeTypes.find(edge); diff --git a/libs/filterGraph/core/filterGraph/MergeFilter.hpp b/libs/filterGraph/core/filterGraph/MergeFilter.hpp index 6789ec8..22a0e42 100644 --- a/libs/filterGraph/core/filterGraph/MergeFilter.hpp +++ b/libs/filterGraph/core/filterGraph/MergeFilter.hpp @@ -1,24 +1,27 @@ #pragma once #include +#include #include #include #include +#include +#include #include #include #include +#include #include +#include #include #include namespace filterGraph { -// The input of a merge stage: one slot per fan-in edge, in the order the edges -// are listed in the DSL group `(a, b, c) -> Merge`. An empty slot is a "hole" -// left by an upstream path that dropped the message via std::nullopt. -using MergeInputs = std::vector; +// MergeInputs (the slots of a fan-in group) and MergeStage (how a merge +// declares its slot types) live in MergeStage.hpp. // MergeFilter is the fan-in stage of the text DSL: it combines the // values arriving on several edges into a single OutputType via a @@ -52,6 +55,10 @@ class MergeFilter : public MessageFilter // Registers MergeFilter under `name` for use as a DSL fan-in stage: // // (a, b) -> Name -> merged +// +// The combiner is copied into every instance of the stage, so state it captures +// is shared by all of them; see TypedMergeFilter/UniformMergeFilter (or a +// hand-written merge stage) for a merge with per-instance state. template void registerMergeFilter(const std::string& name, typename MergeFilter::Combiner combiner) { @@ -62,4 +69,139 @@ void registerMergeFilter(const std::string& name, typename MergeFilter is a merge stage that declares +// the type of every one of its slots, so that the DSL checks the edges of its +// fan-in group when the graph is built instead of failing with a +// std::bad_any_cast on the first message. +// +// The slots arrive as std::optionals, in group order: an empty optional is a +// hole left by a path that dropped the message. Implement merge(): +// +// class Summarize : public TypedMergeFilter +// { +// std::optional merge(std::optional&& message, +// std::optional&& valid) override { ... } +// }; +template +class TypedMergeFilter + : public MessageFilter + , public MergeStage +{ +public: + static_assert(sizeof...(InputTypes) > 0, "a TypedMergeFilter needs at least one slot"); + + // One argument per slot, in group order; std::nullopt for a dropped path. + virtual std::optional merge(std::optional&&... inputs) = 0; + + std::optional filter(MergeInputs&& inputs) final + { + if (inputs.size() != sizeof...(InputTypes)) + { + throw std::invalid_argument(std::format("TypedMergeFilter: expected {} slots but received {}", + sizeof...(InputTypes), + inputs.size())); + } + return mergeSlots(inputs, std::index_sequence_for{}); + } + + MergeSlotTypes mergeInputTypes() const override + { + return {{std::type_index(typeid(InputTypes))...}, false}; + } + +private: + template + std::optional mergeSlots(MergeInputs& inputs, std::index_sequence) + { + // The pack expansion names each slot's type by position, so the + // arguments are built in slot order regardless of evaluation order. + return merge(slot>>(inputs[Slot])...); + } + + template + static std::optional slot(std::any& value) + { + if (!value.has_value()) + { + return std::nullopt; // hole: the path feeding this slot dropped the message + } + return std::any_cast(std::move(value)); + } +}; + +// UniformMergeFilter is a merge stage whose slots all +// carry the same type, for any number of slots: N parallel variants of one +// computation, combined into a single result. +template +class UniformMergeFilter + : public MessageFilter + , public MergeStage +{ +public: + // One entry per slot, in group order; std::nullopt for a dropped path. + virtual std::optional merge(std::vector>&& inputs) = 0; + + std::optional filter(MergeInputs&& inputs) final + { + std::vector> slots; + slots.reserve(inputs.size()); + for (auto& input : inputs) + { + slots.push_back(input.has_value() ? std::optional(std::any_cast(std::move(input))) + : std::nullopt); + } + return merge(std::move(slots)); + } + + MergeSlotTypes mergeInputTypes() const override + { + return {{std::type_index(typeid(InputType))}, true}; + } +}; + +namespace detail { + +// A TypedMergeFilter that calls a caller-supplied function, so that a typed +// merge can be registered from a lambda instead of a subclass. +template +class CallableTypedMergeFilter : public TypedMergeFilter +{ +public: + using Merger = std::function(std::optional&&...)>; + + explicit CallableTypedMergeFilter(Merger merger) + : mMerger(std::move(merger)) + { + } + + std::optional merge(std::optional&&... inputs) override + { + return mMerger(std::move(inputs)...); + } + +private: + Merger mMerger; +}; + +} // namespace detail + +// Registers a typed merge stage built from a function, the typed counterpart of +// registerMergeFilter: +// +// registerTypedMergeFilter( +// "Summarize", +// [](std::optional&& message, std::optional&& valid) -> std::optional { ... }); +// +// As with registerMergeFilter, the function is copied into every instance of +// the stage, so state it captures is shared by all of them. +template +void registerTypedMergeFilter(const std::string& name, + typename detail::CallableTypedMergeFilter::Merger merger) +{ + using Stage = detail::CallableTypedMergeFilter; + FilterRegistrar registrar(name, [merger = std::move(merger)](const nlohmann::json&) { + return std::make_shared(merger); + }); +} + } // namespace filterGraph diff --git a/libs/filterGraph/core/filterGraph/MergeStage.hpp b/libs/filterGraph/core/filterGraph/MergeStage.hpp new file mode 100644 index 0000000..eaac124 --- /dev/null +++ b/libs/filterGraph/core/filterGraph/MergeStage.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +namespace filterGraph { + +// The input of a merge stage: one slot per fan-in edge, in the order the edges +// are listed in the DSL group `(a, b, c) -> Merge`. An empty slot is a "hole" +// left by an upstream path that dropped the message via std::nullopt. +using MergeInputs = std::vector; + +// The slot types a merge stage declares, so that the DSL can check the edges of +// its fan-in group when the graph is built. +struct MergeSlotTypes +{ + // One type per slot, in slot order; a uniform merge lists its single slot + // type. Empty for an untyped merge, whose edges are not checked. + std::vector types; + + // Any number of slots, each of types.front(). + bool uniform = false; + + bool empty() const noexcept + { + return types.empty(); + } +}; + +// Implemented by merge stages (MessageFilter) that declare +// their slot types. TypedMergeFilter and UniformMergeFilter implement it; a +// hand-written merge stage may derive from it too. +class MergeStage +{ +public: + virtual ~MergeStage() = default; + + virtual MergeSlotTypes mergeInputTypes() const = 0; +}; + +} // namespace filterGraph diff --git a/tests/FilterGraphTests/MergeTests.cpp b/tests/FilterGraphTests/MergeTests.cpp new file mode 100644 index 0000000..7665889 --- /dev/null +++ b/tests/FilterGraphTests/MergeTests.cpp @@ -0,0 +1,267 @@ +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace filterGraph; + +namespace { + +class DoubleFilter : public MessageFilter +{ +public: + std::optional filter(int&& value) override + { + return value * 2; + } +}; + +class ToStringFilter : public MessageFilter +{ +public: + std::optional filter(int&& value) override + { + return std::to_string(value); + } +}; + +class DropOddFilter : public MessageFilter +{ +public: + std::optional filter(int&& value) override + { + return value % 2 == 0 ? std::optional(value) : std::nullopt; + } +}; + +// Two slots of different types: "/", with "-" for a hole. +class LabelMerge : public TypedMergeFilter +{ +public: + std::optional merge(std::optional&& text, std::optional&& number) override + { + return std::format("{}/{}", text.value_or("-"), number ? std::to_string(*number) : "-"); + } +}; + +// Any number of int slots, summed. +class SumMerge : public UniformMergeFilter +{ +public: + std::optional merge(std::vector>&& inputs) override + { + int sum = 0; + for (const auto& input : inputs) + { + sum += input.value_or(0); + } + return sum; + } +}; + +// Stateful merge: keeps a running total across messages, so a fresh instance +// starts over. +class TotalMerge : public UniformMergeFilter +{ +public: + std::optional merge(std::vector>&& inputs) override + { + for (const auto& input : inputs) + { + mTotal += input.value_or(0); + } + return mTotal; + } + +private: + int mTotal = 0; +}; + +static FilterRegistrar registerDouble("MergeDouble"); +static FilterRegistrar registerToString("MergeToString"); +static FilterRegistrar registerDropOdd("MergeDropOdd"); +static FilterRegistrar registerLabel("MergeLabel"); +static FilterRegistrar registerSum("MergeSum"); +static FilterRegistrar registerTotal("MergeTotal"); + +static const bool sRegisterUntyped = [] { + registerMergeFilter("MergeUntyped", [](MergeInputs&& inputs) -> std::optional { + return static_cast(inputs.size()); + }); + registerTypedMergeFilter( + "MergeLambda", + [](std::optional&& number, std::optional&& text) -> std::optional { + return std::format("{}:{}", number.value_or(0), text.value_or("-")); + }); + return true; +}(); + +std::vector buildErrors(std::string_view text) +{ + try + { + DslFilterGraph graph(text); + } + catch (const GraphError& error) + { + return error.diagnostics(); + } + return {}; +} + +bool mentions(const std::vector& diagnostics, std::string_view fragment) +{ + return std::any_of(diagnostics.begin(), diagnostics.end(), [&](const dsl::TextDiagnostic& diagnostic) { + return diagnostic.message.find(fragment) != std::string::npos; + }); +} + +} // namespace + +TEST_CASE("TypedMergeFilter receives its slots as typed optionals", "[TypedMerge]") +{ + DslFilterGraph graph(R"( + in -> MergeToString -> text + in -> MergeDouble -> doubled + (text, doubled) -> MergeLabel -> out + )"); + + REQUIRE(graph.filter(21).value() == "21/42"); +} + +TEST_CASE("TypedMergeFilter sees a hole for a dropped path", "[TypedMerge]") +{ + DslFilterGraph graph(R"( + in -> MergeToString -> text + in -> MergeDropOdd -> even + (text, even) -> MergeLabel -> out + )"); + + REQUIRE(graph.filter(4).value() == "4/4"); + REQUIRE(graph.filter(3).value() == "3/-"); +} + +TEST_CASE("registerTypedMergeFilter registers a typed merge from a lambda", "[TypedMerge]") +{ + DslFilterGraph graph(R"( + in -> MergeDouble -> doubled + in -> MergeToString -> text + (doubled, text) -> MergeLambda -> out + )"); + + REQUIRE(graph.filter(5).value() == "10:5"); +} + +TEST_CASE("UniformMergeFilter takes any number of slots of one type", "[TypedMerge]") +{ + DslFilterGraph two(R"( + in -> MergeDouble -> a + in -> MergeDouble -> b + (a, b) -> MergeSum -> out + )"); + REQUIRE(two.filter(3).value() == 12); + + DslFilterGraph three(R"( + in -> MergeDouble -> a + in -> MergeDouble -> b + in -> MergeDropOdd -> c + (a, b, c) -> MergeSum -> out + )"); + REQUIRE(three.filter(3).value() == 12); // the odd input drops slot c + REQUIRE(three.filter(4).value() == 20); +} + +TEST_CASE("A typed merge reports a mis-wired slot when the graph is built", "[TypedMerge]") +{ + const auto swapped = buildErrors(R"( + in -> MergeToString -> text + in -> MergeDouble -> doubled + (doubled, text) -> MergeLabel -> out + )"); + + REQUIRE(swapped.size() == 2); + REQUIRE(swapped[0].message.starts_with("slot 1 of 'MergeLabel' expects")); + REQUIRE(swapped[0].message.find("edge 'doubled' carries") != std::string::npos); + REQUIRE(swapped[1].message.starts_with("slot 2 of 'MergeLabel' expects")); + REQUIRE(swapped[0].loc.line == 4); + + REQUIRE(mentions(buildErrors(R"( + in -> MergeDouble -> a + in -> MergeToString -> b + (a, b) -> MergeSum -> sum -> MergeToString -> out + )"), + "slot 2 of 'MergeSum' expects")); +} + +TEST_CASE("A typed merge reports the wrong number of slots", "[TypedMerge]") +{ + const auto diagnostics = buildErrors(R"( + in -> MergeToString -> text + in -> MergeDouble -> doubled + in -> MergeDouble -> more + (text, doubled, more) -> MergeLabel -> out + )"); + + REQUIRE(diagnostics.size() == 1); + REQUIRE(diagnostics[0].message == "stage 'MergeLabel' takes 2 inputs but the group has 3"); + REQUIRE(diagnostics[0].loc.line == 5); +} + +TEST_CASE("An untyped merge is still accepted and unchecked", "[TypedMerge]") +{ + DslFilterGraph graph(R"( + in -> MergeDouble -> a + in -> MergeToString -> b + (a, b) -> MergeUntyped -> count -> MergeToString -> out + )"); + + REQUIRE(graph.filter(1).value() == "2"); + REQUIRE(validateDslGraph("in -> MergeDouble -> a\nin -> MergeToString -> b\n" + "(a, b) -> MergeUntyped -> c -> MergeToString -> out\n") + .empty()); +} + +TEST_CASE("A stateful merge keeps its state per graph instance", "[TypedMerge]") +{ + const char* text = R"( + in -> MergeDouble -> a + in -> MergeDouble -> b + (a, b) -> MergeTotal -> out + )"; + + DslFilterGraph first(text); + DslFilterGraph second(text); + + REQUIRE(first.filter(1).value() == 4); // 2 + 2 + REQUIRE(first.filter(2).value() == 12); // 4 + (4 + 4) + REQUIRE(second.filter(1).value() == 4); // its own TotalMerge, starting at 0 +} + +TEST_CASE("A typed merge used outside a graph checks its slot count", "[TypedMerge]") +{ + LabelMerge merge; + REQUIRE(merge.filter(MergeInputs{std::any(std::string{"a"}), std::any(7)}).value() == "a/7"); + REQUIRE_THROWS_AS(merge.filter(MergeInputs{std::any(std::string{"a"})}), std::invalid_argument); + + SumMerge sum; + REQUIRE(sum.filter(MergeInputs{std::any(1), std::any(2), std::any(3)}).value() == 6); + + const MergeSlotTypes slots = sum.mergeInputTypes(); + REQUIRE(slots.uniform); + REQUIRE(slots.types.size() == 1); + REQUIRE(slots.types.front() == std::type_index(typeid(int))); + REQUIRE_FALSE(LabelMerge{}.mergeInputTypes().uniform); + REQUIRE(LabelMerge{}.mergeInputTypes().types.size() == 2); +}