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

CIB Guide Chapter 7 of 8

Seq

Sequences whose steps can say "not yet, call me again", and which unwind in reverse when you change your mind. Built from the same graph DSL as flow.

Headers
include/seq/
Namespace
seq
Built on
cib_flow
Size
3 headers, ~190 lines
Good for
power & bring-up

What seq is for

Bringing hardware up is rarely a straight run of function calls. A rail has to settle, a PLL has to lock, a peripheral has to acknowledge. And when something fails half way, the parts already started have to be taken down again, in reverse.

seq is flow’s sibling for exactly that. You describe the order once with the same *, >> and && operators, but each step has two functions — forward and backward — and each returns DONE or NOT_DONE. The built object holds a cursor: you call forward() until it reports DONE, and backward() to unwind.

flow: one call runs everything rails clocks reset run() returns when all have run seq: a cursor you advance and retreat rails clocks reset NOT_DONE: call again forward() / backward() each returns DONE or NOT_DONE
Same graph, different renderer. Flow flattens it into one pass of void() calls; seq keeps a cursor and two arrays of functions, so progress can pause and reverse.
flowseq
Stepvoid()seq::status() forward + backward
ExecutionRuns to completion in one callCursor advanced by repeated calls
ReversibleNoYes, in reverse order
Built objectStatelessHolds the cursor — keep it alive
Nexus serviceYesno build it directly
LoggingNamed flows log every stepnever

Quick start

power.cpp bring-up and tear-down from one description
#include <flow/flow.hpp>     // the DSL: * >> &&
#include <seq/builder.hpp>
#include <seq/impl.hpp>

constexpr auto rail_3v3 = seq::step<"rail_3v3">(
    [] {                                        // forward
        pmic::enable(rail::v3v3);
        return pmic::is_up(rail::v3v3) ? seq::status::DONE : seq::status::NOT_DONE;
    },
    [] {                                        // backward
        pmic::disable(rail::v3v3);
        return seq::status::DONE;
    });

constexpr auto clock_on = seq::step<"clock_on">(
    [] { return pll::locked() ? seq::status::DONE : seq::status::NOT_DONE; },
    [] { pll::off(); return seq::status::DONE; });

constexpr auto release_reset = seq::step<"release_reset">(
    [] { gpio::set(pin::nrst); return seq::status::DONE; },
    [] { gpio::clear(pin::nrst); return seq::status::DONE; });

using power_seq = flow::graph_builder<"power", flow::log_policies::none, seq::impl>;

auto [power, leftover] = power_seq::build(
    seq::builder<>{}.add(*rail_3v3 >> *clock_on >> *release_reset));
// assert(leftover.empty());

// later, from the main loop:
if (power.forward() == seq::status::DONE) { state = state::running; }

// and to shut down, in exactly the reverse order:
if (power.backward() == seq::status::DONE) { state = state::off; }

Link cib_seq. Note that <cib/cib.hpp> does not include seq — include its two headers directly.

Steps

seq::step<"name">(forward, backward) takes two function pointers of type auto (*)() -> seq::status. Both lambdas must be captureless; a capturing one gives no matching function for call to 'step'. Keep state in statics or a driver object the step reaches.

ReturnMeansEffect
seq::status::DONEThis step has finished its work in this directionThe cursor moves on to the next step
seq::status::NOT_DONENot finished yet — call me againThe call returns; the next call re-enters this same step

Steps must not block: returning NOT_DONE and being called again is the waiting mechanism, so the rest of the system keeps running.

Building

build returns the sequencer and the edges it couldn't place
using power_seq = flow::graph_builder<"power", flow::log_policies::none, seq::impl>;

auto [power, leftover] = power_seq::build(
    seq::builder<>{}
        .add(*rail_3v3 >> *rail_1v8)
        .add(*clock_on >> *release_reset)
        .add(rail_1v8 >> clock_on));          // ordering between the two pairs

seq::builder<Name, LogPolicy> is flow::builder_for<flow::graph_builder<Name, LogPolicy, seq::impl>>, so the whole flow DSL applies: * to add, >> to order, && for “no order between these”, "name"_ref to refer to a step by name, and as many .add() calls as you like.

Adding the same step twice is caught, by flow’s check: One or more steps in the flow (dup) are explicitly added more than once using the * operator. The duplicate steps are: s1.

Running

  1. Forward runs graph order, backward the exact reverse. With *a >> *b >> *c: forward a b c, backward c b a.
  2. A step that returns NOT_DONE stops the call. The next call re-enters that same step. A step needing three attempts gives NOT_DONE, NOT_DONE, then DONE — and the third call goes on to run the following steps.
  3. Changing direction finishes the in-progress step first. If forward() left a step unfinished, the next backward() calls that step’s forward function again.
  4. …and then keeps going. That call returns NOT_DONE only while the step is still unfinished. Once it completes, the same call continues unwinding.
  5. Past either end is a no-op. forward() at the end, or backward() at the start, returns DONE and calls nothing. An empty sequence returns DONE both ways.
verified trace first step needs three attempts
forward()   -> NOT_DONE    ran: power_on (attempt 1)
backward()  -> NOT_DONE    ran: power_on (attempt 2)   // finishing the forward step
backward()  -> DONE        ran: power_on (attempt 3), then power_on backward
backward()  -> DONE        ran: nothing (already at the start)

There is no reset. To start over, unwind to the beginning or construct a fresh sequencer.

Sequencer bench

Three steps. power_on needs three forward attempts, release_reset needs two backward attempts, the rest finish immediately. Drive it and watch the cursor: this is the state machine from seq::impl.

power · forward / backward

Highlighted step is where the cursor sits; filled steps are complete.

Sequence

Calls, newest first

    Driving a sequence

    A seq isn’t a nexus service: seq::service has no uninitialized() hook, so exporting one fails with constraints not satisfied for alias template 'builder_t', and components can’t extend it with cib::extend. Build it directly and call it from something that already runs.

    advancing from the main loop
    enum struct power_state { off, coming_up, up, going_down };
    inline auto power_state_v = power_state::off;
    
    struct power_component {
        constexpr static auto STEP = flow::action<"step_power">([] {
            switch (power_state_v) {
            case power_state::coming_up:
                if (power.forward() == seq::status::DONE) { power_state_v = power_state::up; }
                break;
            case power_state::going_down:
                if (power.backward() == seq::status::DONE) { power_state_v = power_state::off; }
                break;
            default:
                break;
            }
        });
    
        constexpr static auto config = cib::config(cib::extend<cib::MainLoop>(*STEP));
    };

    Other homes for the call: a periodic timer flow, a state-machine task, or an interrupt handler’s flow when the sequence is waiting on hardware that raises an IRQ.

    Rest of cib

    LibraryCouplingWhat passes between them
    flowbuilt on The DSL, the graph, the topological sort and the duplicate-step check are all flow’s. seq::impl is just another renderer passed to flow::graph_builder.
    nexusnot a service Build directly; drive from a flow that is a service.
    logunused seq::impl ignores its Name and LogPolicy, and the log_name function each step carries is never called. Log inside your step functions instead.
    interruptpairs well An ISR flow can advance a sequence that is waiting on hardware.

    Use cases

    Power up and power down

    The canonical one. Rails, clocks, reset release — and shutdown for free, in the right order, from the same description. No second list to keep in sync when a rail is added.

    Radio or sensor bring-up with rollback

    Each step is “start this, then wait for its ready bit”. If one never becomes ready, your supervising code stops advancing and unwinds, leaving the hardware as it was.

    Low-power entry and exit

    Entry is backward() over the bring-up sequence; exit is forward() again. Because reversing finishes an in-progress step first, a sleep request that arrives mid-bring-up still leaves the hardware in a defined state.

    Calibration with retries

    A calibration step returns NOT_DONE while iterating, counting attempts in a static. On too many attempts, record the failure and return DONE so the sequence stops advancing; the caller then unwinds.

    Testing a sequence

    drive it explicitly and assert on a trace
    auto [s, leftover] = power_seq::build(seq::builder<>{}.add(*a >> *b));
    CHECK(leftover.empty());
    CHECK(s.forward() == seq::status::DONE);
    CHECK(trace == "ab");
    CHECK(s.backward() == seq::status::DONE);
    CHECK(trace == "abBA");

    Assert leftover.empty() in every test that builds a graph — it’s the only cycle report you get.

    Pitfalls

    SymptomCauseFix
    Jump to a null address on the first forward()A step was added without *, so it was default constructedStar every step
    leftover isn’t empty and the graph looks fineTwo un-starred steps collapsed into one nodeStar every step
    A cycle compiles happilybuild() reports unplaced edges instead of assertingAssert leftover.empty()
    Sequence never finishesA step returns NOT_DONE foreverAdd a retry limit; decide failure yourself
    Steps run again from the startThe sequencer is a local that was rebuiltKeep the built object alive
    No log output from the sequenceseq never logs, whatever the name and policyLog inside the step functions
    constraints not satisfied for alias template 'builder_t'seq::service exported to a nexusBuild directly; drive from a flow (§7)
    no matching function for call to 'step'Capturing lambdaMake it captureless
    no viable conversion … flow::ct_nodeA flow::action was added to a seqUse seq::step, or keep it in a flow