Skip to content
michael.caisse.io michael.caisse.io

CIB Guide Chapter 5 of 8

Match

Predicates that describe themselves, compose like Boolean expressions, and get simplified before the program ever runs.

Headers
include/match/
Namespace
match
Size
13 headers, ~560 lines
Depends on
stdx only
Used by
cib_msg

Why an algebra

A predicate is easy: a function returning bool. The reason match exists is everything that comes after. When predicates are types that know how to negate themselves and when one entails another, the compiler can do the reasoning: fold a contradiction to never, drop a redundant term, rewrite an expression into the cheaper equivalent form, or flatten it into the normal form a dispatch table needs.

The whole library is 13 small headers, and its only consumer in the repo is msg — where every callback’s matcher is simplified, indexed, and printed in log messages when a message doesn’t match.

WHAT YOU WRITE x >= 5 and x < 5 and_t<at_least_t<5>, below_t<5>> simplify negate(below<5>) = at_least<5> implies(at_least<5>, at_least<5>) → contradiction never no runtime test FOR DISPATCH X and (Y or Z) sum_of_products (X and Y) or (X and Z) each term indexable
Two transformations, both at compile time. Simplification shrinks the work; sum of products reshapes it into the form msg's indexer can key on.

Writing a matcher

the entire interface
template <int N> struct is_n_t {
    using is_matcher = void;                        // opt in

    constexpr auto operator()(int i) const -> bool { return i == N; }

    constexpr static auto describe() {              // compile-time text
        return stdx::ct_format<"x == {}">(stdx::ct<N>());
    }
    constexpr auto describe_match(int i) const {    // what happened this time
        return stdx::ct_format<"{} == {}">(i, stdx::ct<N>());
    }
};
template <int N> constexpr auto is_n = is_n_t<N>{};

No base class, no registration. match::matcher<T> checks only for the is_matcher type; match::matcher_for<T, Event> additionally checks that the three members work for that event type.

For one-off conditions there’s match::predicate:

named and anonymous
constexpr auto even = match::predicate<"even">([](int i) { return i % 2 == 0; });
even.describe();   // "even"

constexpr auto anon = match::predicate([](int i) { return i > 0; });
anon.describe();   // "<predicate>"

The lambda must be captureless — the predicate is held as a constexpr static member. A capturing one fails with no matching constructor for initialization of '(lambda …)'.

Composing

WriteGet
a and b, a or b, not aThe composed matcher, simplified
a & b, a | bThe raw and_t/or_t, not simplified
match::all(ms...), match::any(ms...)Simplified folds; empty ones are always / never
match::simplify(m)Simplify something built with &/|
match::sum_of_products(m)Disjunctive normal form (§8)

Simplification

Every one of these is verified, and all of them happen in the type system before main runs:

ExpressionBecomesLaw
X and X / X or XXidempotence
X and always / X or neverXidentity
X and neverneverannihilation
X or alwaysalwaysannihilation
X and not Xnevercomplementation
X or not Xalwayscomplementation
not not XXdouble negation
X or (X and Y)Xabsorption
not X and not Ynot (X or Y)de Morgan, when cheaper

That last one is a choice, not a rule. Each matcher has a cost: 1 for a leaf, plus 1 per operator. The library builds both forms and keeps the cheaper:

verified costs
cost(not (X or Y))        == 4      ← chosen
cost(not X and not Y)     == 5

Simplification recurses: X and (X and always) collapses to X, and a contradiction buried in a longer chain still folds the whole expression to never.

Simplifier bench

Build an expression from two operands and an operator. The bench applies the same rules the library does — implication-driven absorption, complementation, and the cost comparison for de Morgan — and shows what the type would collapse to, plus which values match before and after.

simplify(lhs op rhs)

Leaves: x < n, x >= n, x == n, and the constants.

Left operand

Operator

Right operand

as written
simplified
why
matches

Teaching the library

Out of the box the library knows one implication: a matcher implies itself. Two tag_invoke overloads teach it the rest.

a pair of relational matchers
template <int N> struct below_t {
    // … the four members …
  private:
    // how to invert
    friend constexpr auto tag_invoke(match::negate_t, below_t const &)
        -> at_least_t<N> { return {}; }

    // x < N implies x < M when N <= M
    template <int M>
    friend constexpr auto tag_invoke(match::implies_t, below_t, below_t<M>)
        -> bool { return N <= M; }
};

With only those, all of this follows (verified):

ExpressionBecomesBecause
not below<5>at_least_t<5>your negate, instead of a not_t wrapper
below<3> and below<5>below_t<3>the narrower implies the wider
below<3> or below<5>below_t<5>same implication, dual rule
at_least<5> and below<5>nevereach implies the other’s negation
at_least<5> or below<5>alwaysditto
below<5> and below<10> and at_least<5>neversimplification reaches inside

Implications also chain through the operators: (X and Y) => X, and X => (X or Y), both verified. If your matcher has no natural opposite, skip negate: not then wraps it in not_t, which still works.

Ordering

Because implication is defined, matchers form a partial order: one matcher is “less” than another when it matches fewer things. a <=> b gives a std::partial_ordering (verified):

ComparisonResult
(X and Y) <=> Xless
(X or Y) <=> Xgreater
X <=> Xequivalent
X <=> Y (unrelated)unordered

never and always are the bottom and top of that order. There is no operator== between matchers — m1{} == m1{} is invalid operands to binary expression. Compare with <=>, or compare types.

Sum of products

Disjunctive normal form is an or of ands with negations pushed down to the leaves. It matters because each top-level or term can be indexed independently, which is exactly what msg’s dispatch needs.

verified transformations
X and (Y or Z)   ->  (X and Y) or (X and Z)
(X or Y) and Z   ->  (X and Z) or (Y and Z)
not (X and Y)    ->  not X or not Y
not (X or Y)     ->  not X and not Y

and and or on their own do not produce this form; the conversion is a separate, explicit call. Distribution multiplies terms — (A or B) and (X or Y) becomes four — so convert deliberately, not by habit.

Rest of cib

LibraryCouplingWhat passes between them
msgthe consumer Field matchers (equal_to, in, relational) are match matchers with negate and implies overloads. A callback’s matcher is and-ed with the message definition’s own and converted with sum_of_products; the indexer then strips indexed terms and keeps the residual.
logvia msg describe() and describe_match() are what dispatch logs print when a message matches or doesn’t.
stdxlink ct_string and ct_format for descriptions; that’s the only dependency.

If you’re writing field matchers for a protocol, start from include/msg/field_matchers.hpp: it is a complete worked example of relational matchers with negation, implication and index hooks.

Use cases

Conditions that explain themselves

Any place you’d write a bare lambda guard but want the log to say why it didn’t fire, a matcher pays for itself: describe_match gives the reason with the actual values, formatted only if logged.

Range and threshold checks

Give your relational matchers negate and implies, and impossible ranges become never at compile time instead of dead runtime branches.

Sets of accepted values

match::any(eq<1>, eq<2>, eq<3>) — which is precisely how msg’s in<F, Vs...> is built.

Feature flags in a matcher expression

match::predicate<"feature">([] { return flag; }) composes with everything else and prints its name. cib’s runtime conditions on msg callbacks work exactly this way.

Testing matchers

types for simplification, values for behaviour
// decltype(expression) is unqualified; decltype(constexpr variable) is const
template <typename A, typename B>
constexpr bool same_type =
    std::is_same_v<std::remove_const_t<A>, std::remove_const_t<B>>;

static_assert(same_type<decltype(below<3> and below<5>), below_t<3>>);
static_assert((below<3> and below<5>)(2));
static_assert(e.describe().str == "(x == 1) and (x == 2)"_ctst);

Pitfalls

SymptomCauseFix
Expression keeps its shape instead of simplifyingBuilt with & / |Use and / or, or call match::simplify
Nothing simplifies between your matchersNo implies overload; the default is same-type onlyAdd tag_invoke(implies_t, …)
not produces a not_t wrapperNo negate overloadAdd one if there’s a natural opposite
invalid operands to binary expression== between matchers, or a type missing is_matcherUse <=>; add the is_matcher type
no matching function … implies_timplies called with named operandsPass temporaries
no matching constructor … (lambda …)Capturing lambda in match::predicateMake it captureless
static_assert on types fails for no clear reasondecltype of an expression isn’t const; of a constexpr variable it isCompare up to const
.describe().str doesn’t compileA leaf returning a plain ct_string has no .strCompare the ct_string directly
Compile times climbsum_of_products on a large expressionConvert only where a normal form is needed