diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 0905508..a9a9e84 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -3351,6 +3351,78 @@ "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-001", + "title": "message_timestamp-repurposing request_type decode/encode (mtv=0)", + "text": "rcp::sequencer::decode_request_type shall return SequencerErrc::timestamp_not_repurposed when mtv is true, shall return SequencerErrc::unknown_request_type when the top byte of message_timestamp is not one of the 8 defined opcodes, and shall otherwise recover the opcode and the remaining 7 parameter bytes exactly as produced by encode_request_type for the same inputs; rcp::sequencer::make_conditional_request shall always build an ACF_GBB AcfMessageInfo with mtv clear.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-002", + "title": "Request categories and execution-priority ordering", + "text": "rcp::sequencer::category_of shall map the 3 cancellation opcodes to RequestCategory::Cancellation, each of the 5 conditional opcodes to its own category, and std::nullopt to RequestCategory::Standard; rcp::sequencer::select_next_due shall pick the candidate with the lowest priority_rank among due candidates and shall break ties within a category by lowest arrival_seq (FIFO), returning std::nullopt for an empty input.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-003", + "title": "Compound/compound-wait optional-feature bundling", + "text": "rcp::sequencer::validate_feature_bundles shall return SequencerErrc::compound_bundle_incomplete whenever FeatureSet::compound or FeatureSet::compound_wait is set unless compound, compound_wait, clear_non_safestate, and sequencer_count >= kMinCompoundSequencers (4) are all simultaneously true, and shall accept every other combination of triggered/chained/timed/clear_single independently of compound support.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-004", + "title": "Sequencer-state default value and finalization advance rule", + "text": "rcp::sequencer::SequencerTable::ensure_size shall fill newly added slots with kDefaultState (1), never the storage's own zero-initialization default, shall never shrink existing storage, and try_advance shall increment a sequencer only when its current value equals the supplied expected_start, leaving it unchanged (and reporting out_advanced=false, not an error) otherwise; both accessors shall return SequencerErrc::index_out_of_range for an out-of-bounds index.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-005", + "title": "Cancellation kinds and shared cancellation semantics", + "text": "rcp::sequencer::RequestLedger::cancel_single shall cancel a Pending/Started request and cascade cancellation to every transitive chained successor, and shall return SequencerErrc::request_not_found for an unknown transaction or one that is UnderExecution/Finalized/already Canceled; cancel_all shall cancel every Pending/Started record (optionally skipping is_safety records when non_safestate_only is set) without disturbing any UnderExecution or Finalized record; a canceled request's outcome_of shall report SequencerErrc::request_canceled.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-006", + "title": "Request lifecycle state machine: pending -> started -> under_execution -> finalized", + "text": "rcp::sequencer::RequestLedger shall only permit the single-step forward sequence Pending->Started->UnderExecution->Finalized, returning SequencerErrc::invalid_lifecycle_transition for any other requested transition and SequencerErrc::unknown_transaction for an untracked id; submit shall return SequencerErrc::transaction_num_collision for a transaction_num already tracked; finalize shall drive SequencerTable::try_advance for a Compound/CompoundWait record's sequencer_index/expected_start_state as its side effect.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-007", + "title": "Chained-successor propagation on predecessor finalization", + "text": "rcp::sequencer::RequestLedger::finalize shall, for every record whose chained_predecessor equals the finalized transaction_num, cascade-cancel that successor iff rcp::sequencer::should_execute_chained(successor.cs, errored) is false, and shall leave every such successor untouched when the predecessor finalized with errored=false.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-008", + "title": "The cs field's dual meaning (compound-wait vs. chained)", + "text": "rcp::sequencer::compound_wait_check_of shall map cs=true to CompoundWaitCheck::Immediate and cs=false to CompoundWaitCheck::AfterChangeOnly; rcp::sequencer::should_execute_chained shall return true whenever cs is true regardless of predecessor_errored, and shall return true iff predecessor_errored is false when cs is false.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SEQ-009", + "title": "SequencerErrc is a distinct error category with non-empty, distinct messages", + "text": "Every rcp::sequencer::SequencerErrc value shall report rcp::sequencer::sequencer_category() as its category and shall have a non-empty diagnostic message distinct from every other SequencerErrc value's message.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" } ] } diff --git a/CMakeLists.txt b/CMakeLists.txt index 47d02e2..93c4329 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.4.0 + VERSION 2.5.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/ROADMAP.md b/ROADMAP.md index d780fb0..8546aae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -430,6 +430,48 @@ key off of. New coverage lives in `tests/test_i2c.cpp` (REQ-I2C-001..005), ### 49. Conditional-Request Taxonomy & Sequencers (v2.5.0) +**Done (v2.5.0):** `rcp/sequencer.hpp` implements the taxonomy and state +machine below entirely as a new decode/behavior layer over already-existing +substrate — no change to `rcp/wire.hpp`'s ACF_GBB codec or +`rcp/regmap.hpp`'s `sequencer_states` storage was needed. `decode_request_type` +/`encode_request_type` implement the `mtv=0` message_timestamp-repurposing +trick (`RequestTypeOpcode`'s 8 values: the 5 conditional kinds plus the 3 +cancellation kinds), gated so a genuinely timestamped ACF_GBB message +(`mtv=1`) is rejected rather than misread; `make_conditional_request` builds +the matching `AcfMessageInfo` for `rcp::wire::encode_acf_gbb` to carry +unchanged. `RequestCategory`/`category_of`/`priority_rank`/`select_next_due` +implement the seven-way execution-priority ordering (cancellation → +triggered → timed → compound → compound-wait → chained → standard) with FIFO +tie-break. `FeatureSet`/`validate_feature_bundles` enforce the compound +bundle rule exactly as the roadmap states it: compound and compound-wait can +only be claimed together, alongside clear-non-safestate cancellation and +`sequencer_count >= kMinCompoundSequencers` (4) — triggered, chained, and +timed remain independently flaggable. `SequencerTable` is the behavior layer +over `regmap::RegisterMap::sequencer_states`: `kDefaultState` (1) fills newly +grown slots (not the vector's own zero default), and `try_advance` advances a +sequencer only while it still holds its expected start value, otherwise +leaving it untouched without erroring. `RequestState`/`RequestRecord`/ +`RequestLedger` implement the forward-only pending → started → +under_execution → finalized lifecycle (mirroring `rcp/lifecycle.hpp`'s own +forward-only style), the three cancellation kinds' shared semantics +(`cancel_single`/`cancel_all`, `REQUEST_CANCELED`/`REQUEST_NOT_FOUND` via +`SequencerErrc`, already-executing requests left to finish, cascade-cancel +into chained successors), and `finalize`'s two integration points: driving +`SequencerTable::try_advance` for a Compound/CompoundWait record, and +`propagate_chain_completion` evaluating each chained successor's `cs`-gated +abort-on-predecessor-error rule via `should_execute_chained`. +`compound_wait_check_of` and `should_execute_chained` implement the `cs` +field's two independent meanings (compound-wait's immediate-vs-after-change +check; chained's execute-regardless-vs-abort-on-error). This header +deliberately does not run a scheduler thread — `select_next_due`'s output and +`RequestLedger`'s transition methods are primitives for the embedding +application to drive, same as every other endpoint header in this codebase. +New coverage lives in `tests/test_sequencer.cpp` (REQ-SEQ-001..009), traced +in `.fusa-reqs.json`. Full wire-level conformance (the safety-tagged `0x8x` +variants and E2E CRC safe points this milestone's cancellation model already +carries an `is_safety` field in anticipation of) remains a v2.6.0 concern per +the Phase 13 introduction above. + - Implement the `message_timestamp`-field-repurposing trick: when `mtv=0` on an ACF_GBB message, the first byte of the otherwise-unused 64-bit timestamp slot becomes a `request_type` opcode, with the diff --git a/include/rcp/sequencer.hpp b/include/rcp/sequencer.hpp new file mode 100644 index 0000000..10886a0 --- /dev/null +++ b/include/rcp/sequencer.hpp @@ -0,0 +1,593 @@ +// fusa:req REQ-SEQ-001 +// fusa:req REQ-SEQ-002 +// fusa:req REQ-SEQ-003 +// fusa:req REQ-SEQ-004 +// fusa:req REQ-SEQ-005 +// fusa:req REQ-SEQ-006 +// fusa:req REQ-SEQ-007 +// fusa:req REQ-SEQ-008 +// fusa:req REQ-SEQ-009 + +// Conditional-request taxonomy and sequencer-state primitives — the +// message_timestamp-repurposing decode, the five conditional request kinds +// (plus the three cancellation kinds) and their optional-feature bundling, +// sequencer-state advance/reset, cancellation semantics, and the request +// lifecycle state machine an OPEN Alliance TC18 Remote Control Protocol +// Specification v0.5.1_RC server needs once it goes beyond the mandatory +// "standard" request kind (extraction §2.4, §2.7, §3.1, §3.11, §3.14). +// +// ROADMAP.md milestone 49, "Conditional-Request Taxonomy & Sequencers +// (v2.5.0)": this header rides on top of rcp/wire.hpp's existing ACF_GBB +// support (v2.0.0) and rcp/regmap.hpp's existing `sequencer_states` storage +// (v2.1.0) without changing either — the mtv=0 repurposing trick is a new +// decode path over already-implemented fields, and sequencer *behavior* +// (default value, advance-on-finalization) is built on top of regmap.hpp's +// already-allocated storage rather than duplicating it. It also consumes +// rcp/spi.hpp's compound_wait_matches and rcp/i2c.hpp's +// compound_wait_matches_bits as the per-endpoint-type condition-comparison +// rules a real compound-wait dispatch loop would call once it decides a +// compound-wait request is due; this header does not re-implement either. +// +// Field names and behavior below implement TC18's *behavior* as described in +// an internal structured extraction of the specification named above; no +// text from that document is reproduced here. The concrete opcode-to-byte +// mapping, the param-byte layout within the repurposed timestamp slot, and +// the request-ledger data model chosen in this file are this +// implementation's own encoding of that behavior, same as the equivalent +// disclaimers in rcp/wire.hpp, rcp/regmap.hpp, rcp/endpoint.hpp, +// rcp/spi.hpp, and rcp/i2c.hpp. This header models the taxonomy, the +// bundling rules, and the state machine's transitions and effects — it does +// not implement a running scheduler thread; wiring select_next_due()'s +// output into an actual dispatch loop is left to the embedding application, +// same as every other endpoint header in this codebase. +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rcp { +namespace sequencer { + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class SequencerErrc : int { + timestamp_not_repurposed = 1, // mtv=1: message_timestamp is a real timestamp, not a request_type opcode + unknown_request_type = 2, // byte 0 of the repurposed slot is not one of the defined opcodes + index_out_of_range = 3, // SequencerTable index >= table size + unknown_transaction = 4, // RequestLedger lookup by transaction_num failed + invalid_lifecycle_transition = 5, // requested state transition is not the single next step + transaction_num_collision = 6, // submit() with a transaction_num already tracked + request_not_found = 7, // REQUEST_NOT_FOUND — cancel_single target unknown or past cancellation + request_canceled = 8, // REQUEST_CANCELED — recorded outcome of a canceled request + compound_bundle_incomplete = 9, // compound/compound-wait claimed without every required companion capability +}; + +inline const std::error_category& sequencer_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.sequencer"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case SequencerErrc::timestamp_not_repurposed: + return "rcp/sequencer: mtv=1 — message_timestamp is not a repurposed request_type slot"; + case SequencerErrc::unknown_request_type: + return "rcp/sequencer: unrecognized request_type opcode"; + case SequencerErrc::index_out_of_range: + return "rcp/sequencer: sequencer index out of range"; + case SequencerErrc::unknown_transaction: + return "rcp/sequencer: transaction_num is not tracked by this ledger"; + case SequencerErrc::invalid_lifecycle_transition: + return "rcp/sequencer: requested lifecycle transition is not the next state in sequence"; + case SequencerErrc::transaction_num_collision: + return "rcp/sequencer: transaction_num is already tracked by this ledger"; + case SequencerErrc::request_not_found: + return "rcp/sequencer: REQUEST_NOT_FOUND"; + case SequencerErrc::request_canceled: + return "rcp/sequencer: REQUEST_CANCELED"; + case SequencerErrc::compound_bundle_incomplete: + return "rcp/sequencer: compound support requires compound-wait, clear-non-safestate, " + "and >=4 sequencers together, not compound alone"; + default: + return "rcp/sequencer: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(SequencerErrc e) noexcept { + return {static_cast(e), sequencer_category()}; +} + +// ── request_type opcode — the message_timestamp-repurposing trick ──────────── +// When an ACF_GBB message's `mtv` bit is clear, the 64-bit message_timestamp +// slot it would otherwise carry (and which ACF_GBB always reserves space for +// regardless of `mtv`, per rcp/wire.hpp) is repurposed: its first byte +// becomes this opcode, and the remaining 7 bytes carry opcode-specific +// parameters (extraction §2.7). The eight opcodes below are every kind this +// milestone defines: five conditional request kinds plus three cancellation +// kinds. The mandatory "standard" request kind (rcp::wire::RequestKind) has +// no opcode here at all — it is always carried as ACF_ABB, which has no +// message_timestamp slot to repurpose in the first place. + +enum class RequestTypeOpcode : uint8_t { + Chained = 0x01, + ClearAll = 0x05, + ClearNonSafestate = 0x06, + ClearSingle = 0x07, + Timed = 0x0A, + CompoundWait = 0x0B, + Triggered = 0x0E, + Compound = 0x0F, +}; + +constexpr bool is_valid_request_type(uint8_t byte) noexcept { + switch (static_cast(byte)) { + case RequestTypeOpcode::Chained: + case RequestTypeOpcode::ClearAll: + case RequestTypeOpcode::ClearNonSafestate: + case RequestTypeOpcode::ClearSingle: + case RequestTypeOpcode::Timed: + case RequestTypeOpcode::CompoundWait: + case RequestTypeOpcode::Triggered: + case RequestTypeOpcode::Compound: + return true; + default: + return false; + } +} + +// encode_request_type packs `type` into the top byte and `params` into the +// remaining 7 bytes of a 64-bit value, most-significant byte first — the +// same big-endian convention rcp/wire.hpp's put_u64 uses elsewhere in this +// codebase, kept consistent here even though this file does not call that +// (internal, unexported) helper directly. +constexpr uint64_t encode_request_type(RequestTypeOpcode type, const std::array& params) noexcept { + uint64_t v = static_cast(static_cast(type)) << 56; + for (size_t i = 0; i < 7; ++i) + v |= static_cast(params[i]) << (48 - 8 * i); + return v; +} + +// decode_request_type is the inverse of encode_request_type, gated on `mtv` +// being clear — a caller that has not first checked AcfMessageInfo::mtv (or +// that is decoding a genuinely timestamped ACF_GBB message) gets a decode +// failure rather than silently misreading a real timestamp's high byte as an +// opcode, matching this codebase's established preference (see +// rcp/discovery.hpp's NTSCF-only enforcement) for modeling a precondition +// violation as a returned error rather than an implicit caller obligation. +inline std::error_code decode_request_type(bool mtv, uint64_t message_timestamp, + RequestTypeOpcode& out_type, + std::array& out_params) noexcept { + if (mtv) return make_error_code(SequencerErrc::timestamp_not_repurposed); + const uint8_t byte0 = static_cast((message_timestamp >> 56) & 0xFF); + if (!is_valid_request_type(byte0)) return make_error_code(SequencerErrc::unknown_request_type); + out_type = static_cast(byte0); + for (size_t i = 0; i < 7; ++i) + out_params[i] = static_cast((message_timestamp >> (48 - 8 * i)) & 0xFF); + return {}; +} + +// make_conditional_request builds the AcfMessageInfo header for a +// conditional/cancellation request: always ACF_GBB, always mtv=false (the +// repurposing trick above requires it), with `cs` set per the caller's +// request-kind-specific meaning (see the `cs` section below). Callers pass +// the result to rcp::wire::encode_acf_gbb alongside encode_request_type's +// output as the message_timestamp argument — no new wire encoding is +// introduced here, only this semantic layer over the existing one. +inline wire::AcfMessageInfo make_conditional_request(wire::ByteBusId bus_id, uint8_t transaction_num, + bool cs) noexcept { + wire::AcfMessageInfo info; + info.acf_msg_type = wire::kAcfMsgTypeGbb; + info.mtv = false; + info.byte_bus_id = bus_id; + info.transaction_num = transaction_num; + info.cs = cs; + return info; +} + +// ── Request categories & execution-priority ordering ────────────────────────── +// The seven categories simultaneously-due requests are ordered across +// (extraction §3.14). Numeric values double as priority rank — lower value +// wins — so priority_rank below is a trivial cast rather than a lookup +// table that could drift out of sync with declaration order. + +enum class RequestCategory : uint8_t { + Cancellation = 0, + Triggered = 1, + Timed = 2, + Compound = 3, + CompoundWait = 4, + Chained = 5, + Standard = 6, +}; + +// category_of maps a decoded request_type opcode onto its priority category; +// `std::nullopt` denotes the mandatory standard request kind (no opcode, per +// the header comment above). +constexpr RequestCategory category_of(std::optional kind) noexcept { + if (!kind.has_value()) return RequestCategory::Standard; + switch (*kind) { + case RequestTypeOpcode::ClearAll: + case RequestTypeOpcode::ClearNonSafestate: + case RequestTypeOpcode::ClearSingle: + return RequestCategory::Cancellation; + case RequestTypeOpcode::Triggered: return RequestCategory::Triggered; + case RequestTypeOpcode::Timed: return RequestCategory::Timed; + case RequestTypeOpcode::Compound: return RequestCategory::Compound; + case RequestTypeOpcode::CompoundWait: return RequestCategory::CompoundWait; + case RequestTypeOpcode::Chained: return RequestCategory::Chained; + default: return RequestCategory::Standard; // unreachable given a validated opcode + } +} + +constexpr uint8_t priority_rank(RequestCategory c) noexcept { return static_cast(c); } + +// DueCandidate is the minimal shape select_next_due needs: which category a +// due request belongs to, and its arrival order for the FIFO tie-break. It +// deliberately does not reference RequestRecord below, so the priority +// algorithm stays testable independent of the ledger that owns real +// records. +struct DueCandidate { + RequestCategory category = RequestCategory::Standard; + size_t arrival_seq = 0; +}; + +// select_next_due picks the winning index out of `due` — every entry is +// assumed already due, this function only orders them — by lowest +// priority_rank, breaking ties by lowest arrival_seq (extraction §3.14: +// "ties resolved FIFO"). Returns std::nullopt for an empty input rather than +// an out-of-range index. +inline std::optional select_next_due(const std::vector& due) noexcept { + if (due.empty()) return std::nullopt; + size_t best = 0; + for (size_t i = 1; i < due.size(); ++i) { + const auto& cur = due[i]; + const auto& best_c = due[best]; + if (priority_rank(cur.category) < priority_rank(best_c.category) || + (priority_rank(cur.category) == priority_rank(best_c.category) && + cur.arrival_seq < best_c.arrival_seq)) { + best = i; + } + } + return best; +} + +// ── The `cs` field's two request-kind-specific meanings ─────────────────────── +// AcfMessageInfo::cs (rcp/wire.hpp) is a single wire bit whose meaning +// depends entirely on which conditional request kind it rides on (extraction +// §2.4): for compound-wait it selects when the wait condition is first +// checked; for chained it selects whether a successor cares about its +// predecessor's outcome at all. + +enum class CompoundWaitCheck : uint8_t { + Immediate = 0, // evaluate the condition against current status right away + AfterChangeOnly = 1, // only evaluate once the monitored value changes from its current one +}; + +constexpr CompoundWaitCheck compound_wait_check_of(bool cs) noexcept { + return cs ? CompoundWaitCheck::Immediate : CompoundWaitCheck::AfterChangeOnly; +} + +// should_execute_chained answers, for one chained successor: given its own +// `cs` bit and whether its predecessor finished in error, should this +// successor still run? cs=true means "execute regardless"; cs=false means +// "abort if the predecessor errored". A predecessor that did *not* error +// always permits the successor to run, independent of cs. +constexpr bool should_execute_chained(bool cs, bool predecessor_errored) noexcept { + return cs || !predecessor_errored; +} + +// ── Optional-feature bundling ────────────────────────────────────────────────── +// The roadmap is explicit that compound support cannot be claimed piecemeal: +// a repo implementing compound (0x0F) must also implement compound-wait +// (0x0B), clear-non-safestate cancellation (0x06), and at least 4 sequencer +// slots — all four together, or none of them advertised as supported. +// Triggered, chained, and timed are each independently flaggable; clear- +// single is its own "enhanced cancellation" capability with no further +// dependency modeled here (extraction §3.1, §2.7). + +constexpr size_t kMinCompoundSequencers = 4; + +struct FeatureSet { + bool compound = false; + bool compound_wait = false; + bool triggered = false; + bool chained = false; + bool timed = false; + bool clear_non_safestate = false; + bool clear_single = false; + size_t sequencer_count = 0; +}; + +// validate_feature_bundles enforces the compound/compound-wait bundle rule +// above. It takes no position on triggered/chained/timed/clear_single, which +// this milestone's scope leaves independently flaggable. +inline std::error_code validate_feature_bundles(const FeatureSet& f) noexcept { + const bool claims_compound_support = f.compound || f.compound_wait; + if (!claims_compound_support) return {}; + const bool complete = f.compound && f.compound_wait && f.clear_non_safestate && + f.sequencer_count >= kMinCompoundSequencers; + return complete ? std::error_code{} : make_error_code(SequencerErrc::compound_bundle_incomplete); +} + +// implemented_options_bits reports the svr_implemented_options bit(s) +// (rcp/regmap.hpp) a register map should advertise for a validated feature +// set. Returns 0 for a feature set with nothing conditional enabled at all — +// callers should not report kOptConditionalRequests unless at least one of +// the five conditional kinds is actually implemented. +inline uint32_t implemented_options_bits(const FeatureSet& f) noexcept { + const bool any_conditional = f.compound || f.compound_wait || f.triggered || f.chained || f.timed; + return any_conditional ? regmap::kOptConditionalRequests : 0; +} + +// ── Sequencer-state registers ────────────────────────────────────────────────── +// SequencerTable is the behavior layer over rcp/regmap.hpp's already- +// allocated `RegisterMap::sequencer_states` storage (extraction §3.11, +// §3.14): every sequencer defaults to kDefaultState (not the vector's own +// zero-initialization default), and a sequencer only advances when a +// compound request finalizes while that sequencer is still holding its +// expected start value — a mismatch leaves the sequencer untouched rather +// than erroring, since "the sequencer moved on already" is an ordinary +// outcome of concurrent compound requests racing, not a fault. +class SequencerTable { +public: + static constexpr regmap::SequencerState kDefaultState = 1; + + explicit SequencerTable(std::vector& states) noexcept : states_(states) {} + explicit SequencerTable(regmap::RegisterMap& regs) noexcept : states_(regs.sequencer_states) {} + + // ensure_size grows (never shrinks) the underlying storage to at least + // `count` entries, filling newly added slots with kDefaultState. + void ensure_size(size_t count) { + if (states_.size() < count) states_.resize(count, kDefaultState); + } + + size_t size() const noexcept { return states_.size(); } + + std::error_code state_of(size_t index, regmap::SequencerState& out) const noexcept { + if (index >= states_.size()) return make_error_code(SequencerErrc::index_out_of_range); + out = states_[index]; + return {}; + } + + // try_advance implements the compound-request finalization rule + // directly: the sequencer at `index` advances by one (wrapping at the + // 8-bit storage width, matching the "persistent 8-bit state" the roadmap + // specifies) iff its current value equals `expected_start`; otherwise it + // is left untouched. `out_advanced` reports which happened; the returned + // std::error_code is reserved for the index-out-of-range case only, so + // callers cannot mistake "didn't advance because the expected state did + // not match" for a failure of the call itself. + std::error_code try_advance(size_t index, regmap::SequencerState expected_start, + bool& out_advanced) noexcept { + if (index >= states_.size()) return make_error_code(SequencerErrc::index_out_of_range); + if (states_[index] == expected_start) { + states_[index] = static_cast(states_[index] + 1); + out_advanced = true; + } else { + out_advanced = false; + } + return {}; + } + + void reset_all_to_default() noexcept { std::fill(states_.begin(), states_.end(), kDefaultState); } + +private: + std::vector& states_; +}; + +// ── Request lifecycle state machine ──────────────────────────────────────────── +// pending -> started -> under_execution -> finalized, forward-only and +// single-step (extraction §3.14), mirroring rcp/lifecycle.hpp's own +// forward-only ServerState progression. `canceled` is a separate terminal +// state reachable only from pending/started — extraction §2.7's rule that +// "already-executing requests finish" means under_execution and finalized +// requests are never canceled by this ledger, only left to complete. + +enum class RequestState : uint8_t { + Pending, + Started, + UnderExecution, + Finalized, + Canceled, +}; + +// RequestRecord is one tracked conditional/cancellation/standard request. +// `request_type` is std::nullopt for a standard request (see category_of +// above); chained-successor bookkeeping (`chained_predecessor`, +// `chained_successors`) is populated by the caller when it submits a chained +// request, since only the caller knows the wire-level identifier a +// predecessor/successor pair share. +struct RequestRecord { + uint8_t transaction_num = 0; + std::optional request_type; + RequestState state = RequestState::Pending; + bool cs = false; + bool is_safety = false; // always false until v2.6.0's 0x8x safety variants exist + size_t arrival_seq = 0; // assigned by RequestLedger::submit, FIFO tie-break key + + // Compound / compound-wait finalization target (unused by other kinds). + std::optional sequencer_index; + regmap::SequencerState expected_start_state = SequencerTable::kDefaultState; + + // Chained-request linkage (unused by other kinds). + std::optional chained_predecessor; + std::vector chained_successors; + + std::optional outcome; // set to request_canceled once state == Canceled +}; + +// RequestLedger tracks every in-flight request's lifecycle state and +// implements the cancellation and chained-abort semantics that act across +// records (extraction §2.7, §3.14). It does not itself decide *when* a +// request is due — that is select_next_due's job, fed by the embedding +// application's own timing/trigger/compound-wait-condition evaluation — this +// class only enforces what a valid transition sequence and a valid +// cancellation outcome look like once a decision has been made. +class RequestLedger { +public: + // submit registers a new record in the Pending state. Rejects a + // transaction_num already tracked by this ledger (finalized/canceled + // records remain tracked, not cleared, so a client cannot silently + // resurrect a completed transaction_num while this ledger instance + // lives — callers that want to reuse ids across sessions should + // construct a fresh ledger). + std::error_code submit(RequestRecord rec) noexcept { + if (find(rec.transaction_num) != nullptr) + return make_error_code(SequencerErrc::transaction_num_collision); + rec.state = RequestState::Pending; + rec.arrival_seq = next_arrival_seq_++; + records_.push_back(std::move(rec)); + return {}; + } + + std::error_code start(uint8_t txn) noexcept { return transition(txn, RequestState::Pending, RequestState::Started); } + + std::error_code begin_execution(uint8_t txn) noexcept { + return transition(txn, RequestState::Started, RequestState::UnderExecution); + } + + // finalize completes a request that is currently UnderExecution. For a + // Compound/CompoundWait request whose sequencer_index is set, it drives + // SequencerTable::try_advance for that sequencer as the finalization + // side effect the roadmap calls out — advance-or-not is only ever a + // side effect here, never a precondition for finalizing. `errored` + // records whether this request's own execution failed, which + // propagate_chain_completion (below) needs to evaluate its chained + // successors' cs-gated abort rule. + std::error_code finalize(uint8_t txn, bool errored, SequencerTable* sequencers = nullptr) noexcept { + auto ec = transition(txn, RequestState::UnderExecution, RequestState::Finalized); + if (ec) return ec; + auto* rec = find_mut(txn); + if (rec != nullptr && sequencers != nullptr && rec->sequencer_index.has_value() && + (rec->request_type == RequestTypeOpcode::Compound || + rec->request_type == RequestTypeOpcode::CompoundWait)) { + bool advanced = false; + sequencers->try_advance(*rec->sequencer_index, rec->expected_start_state, advanced); + } + propagate_chain_completion(txn, errored); + return {}; + } + + // cancel_single implements clear-single (0x07): cancels `txn` and every + // transitive chained successor if `txn` is still Pending/Started; + // returns REQUEST_NOT_FOUND if `txn` is untracked, already Canceled, or + // has moved past cancellation (UnderExecution/Finalized — "already- + // executing requests finish", extraction §2.7). + std::error_code cancel_single(uint8_t txn) noexcept { + auto* rec = find_mut(txn); + if (rec == nullptr) return make_error_code(SequencerErrc::request_not_found); + if (rec->state == RequestState::UnderExecution || rec->state == RequestState::Finalized || + rec->state == RequestState::Canceled) + return make_error_code(SequencerErrc::request_not_found); + cascade_cancel(txn); + return {}; + } + + // cancel_all implements clear-all (0x05, non_safestate_only=false, the + // mandatory baseline) and clear-non-safestate (0x06, non_safestate_only + // =true, part of the compound bundle): cancels every currently + // Pending/Started record (and, for each, its chained successors), + // optionally skipping records flagged is_safety. Returns the count of + // top-level records this call transitioned to Canceled (successors + // cascaded from them are not counted separately). + size_t cancel_all(bool non_safestate_only) noexcept { + size_t count = 0; + for (auto& rec : records_) { + if ((rec.state != RequestState::Pending && rec.state != RequestState::Started)) continue; + if (non_safestate_only && rec.is_safety) continue; + cascade_cancel(rec.transaction_num); + ++count; + } + return count; + } + + const RequestRecord* find(uint8_t txn) const noexcept { + for (const auto& rec : records_) + if (rec.transaction_num == txn) return &rec; + return nullptr; + } + + std::error_code outcome_of(uint8_t txn, std::error_code& out) const noexcept { + const auto* rec = find(txn); + if (rec == nullptr) return make_error_code(SequencerErrc::unknown_transaction); + out = rec->outcome.value_or(std::error_code{}); + return {}; + } + + size_t size() const noexcept { return records_.size(); } + +private: + RequestRecord* find_mut(uint8_t txn) noexcept { + for (auto& rec : records_) + if (rec.transaction_num == txn) return &rec; + return nullptr; + } + + std::error_code transition(uint8_t txn, RequestState from, RequestState to) noexcept { + auto* rec = find_mut(txn); + if (rec == nullptr) return make_error_code(SequencerErrc::unknown_transaction); + if (rec->state != from) return make_error_code(SequencerErrc::invalid_lifecycle_transition); + rec->state = to; + return {}; + } + + // cascade_cancel marks `txn` Canceled (idempotent, and a no-op for a + // record that has moved past cancellation) and recurses into its + // chained_successors — extraction §2.7's "cancelling a chained request + // cancels its successors" rule. + void cascade_cancel(uint8_t txn) noexcept { + auto* rec = find_mut(txn); + if (rec == nullptr) return; + if (rec->state == RequestState::UnderExecution || rec->state == RequestState::Finalized || + rec->state == RequestState::Canceled) + return; + rec->state = RequestState::Canceled; + rec->outcome = make_error_code(SequencerErrc::request_canceled); + for (uint8_t succ : rec->chained_successors) cascade_cancel(succ); + } + + // propagate_chain_completion is finalize()'s hook into the chained `cs` + // rule (should_execute_chained above): every record whose + // chained_predecessor is `predecessor_txn` is aborted (cascade_cancel) + // iff its own cs does not permit executing after a predecessor error. + // A predecessor that finished without error never triggers an abort + // here, regardless of any successor's cs. + void propagate_chain_completion(uint8_t predecessor_txn, bool predecessor_errored) noexcept { + // Collect matching successors first: cascade_cancel can mutate + // records_ only in-place (no insertion/removal), but iterating and + // mutating the same vector via index is simpler to reason about + // than iterator invalidation rules, so gather ids first. + std::vector successors; + for (const auto& rec : records_) + if (rec.chained_predecessor.has_value() && *rec.chained_predecessor == predecessor_txn) + successors.push_back(rec.transaction_num); + for (uint8_t succ : successors) { + const auto* rec = find(succ); + if (rec != nullptr && !should_execute_chained(rec->cs, predecessor_errored)) + cascade_cancel(succ); + } + } + + std::vector records_; + size_t next_arrival_seq_ = 0; +}; + +} // namespace sequencer +} // namespace rcp + +// Enable std::error_code construction from rcp::sequencer::SequencerErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index d39e37d..c2b0de0 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.4.0"; +constexpr std::string_view kVersion = "2.5.0"; } // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e5dbe55..937ad45 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,10 @@ add_executable(test_pwm test_pwm.cpp) target_link_libraries(test_pwm PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_pwm COMMAND test_pwm) +add_executable(test_sequencer test_sequencer.cpp) +target_link_libraries(test_sequencer PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_sequencer COMMAND test_sequencer) + add_executable(test_e2e test_e2e.cpp) target_link_libraries(test_e2e PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_e2e COMMAND test_e2e) diff --git a/tests/test_sequencer.cpp b/tests/test_sequencer.cpp new file mode 100644 index 0000000..07400bd --- /dev/null +++ b/tests/test_sequencer.cpp @@ -0,0 +1,553 @@ +// fusa:test REQ-SEQ-001 +// fusa:test REQ-SEQ-002 +// fusa:test REQ-SEQ-003 +// fusa:test REQ-SEQ-004 +// fusa:test REQ-SEQ-005 +// fusa:test REQ-SEQ-006 +// fusa:test REQ-SEQ-007 +// fusa:test REQ-SEQ-008 +// fusa:test REQ-SEQ-009 + +// Tests for rcp/sequencer.hpp — conditional-request taxonomy and +// sequencer-state primitives (ROADMAP.md milestone 49, +// "Conditional-Request Taxonomy & Sequencers", v2.5.0). + +#include +#include + +using namespace rcp::sequencer; + +// ── message_timestamp-repurposing trick (mtv=0) ────────────────────────────── + +TEST_CASE("encode_request_type/decode_request_type round-trip", "[sequencer][REQ-SEQ-001]") { + std::array params{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77}; + const uint64_t ts = encode_request_type(RequestTypeOpcode::Compound, params); + + RequestTypeOpcode type{}; + std::array out_params{}; + auto ec = decode_request_type(/*mtv=*/false, ts, type, out_params); + REQUIRE_FALSE(ec); + REQUIRE(type == RequestTypeOpcode::Compound); + REQUIRE(out_params == params); +} + +TEST_CASE("decode_request_type rejects mtv=1 as not a repurposed slot", "[sequencer][REQ-SEQ-001]") { + RequestTypeOpcode type{}; + std::array out_params{}; + auto ec = decode_request_type(/*mtv=*/true, 0xFFFFFFFFFFFFFFFFull, type, out_params); + REQUIRE(ec == make_error_code(SequencerErrc::timestamp_not_repurposed)); +} + +TEST_CASE("decode_request_type rejects an unrecognized opcode byte", "[sequencer][REQ-SEQ-001]") { + RequestTypeOpcode type{}; + std::array out_params{}; + // 0x02 is not one of the 8 defined opcodes. + const uint64_t ts = uint64_t{0x02} << 56; + auto ec = decode_request_type(/*mtv=*/false, ts, type, out_params); + REQUIRE(ec == make_error_code(SequencerErrc::unknown_request_type)); +} + +TEST_CASE("make_conditional_request always sets ACF_GBB with mtv clear", "[sequencer][REQ-SEQ-001]") { + auto info = make_conditional_request(/*bus_id=*/7, /*transaction_num=*/9, /*cs=*/true); + REQUIRE(info.acf_msg_type == rcp::wire::kAcfMsgTypeGbb); + REQUIRE_FALSE(info.mtv); + REQUIRE(info.byte_bus_id == 7); + REQUIRE(info.transaction_num == 9); + REQUIRE(info.cs); +} + +TEST_CASE("make_conditional_request round-trips through wire::encode_acf_gbb/decode_acf_gbb", + "[sequencer][REQ-SEQ-001]") { + std::array params{1, 2, 3, 4, 5, 6, 7}; + auto info = make_conditional_request(3, 42, false); + auto ts = encode_request_type(RequestTypeOpcode::Triggered, params); + auto buf = rcp::wire::encode_acf_gbb(info, ts, {}); + + rcp::wire::AcfMessageInfo decoded_info; + uint64_t decoded_ts = 0; + std::vector decoded_payload; + auto ec = rcp::wire::decode_acf_gbb(buf.data(), buf.size(), decoded_info, decoded_ts, decoded_payload); + REQUIRE_FALSE(ec); + REQUIRE_FALSE(decoded_info.mtv); + + RequestTypeOpcode type{}; + std::array out_params{}; + REQUIRE_FALSE(decode_request_type(decoded_info.mtv, decoded_ts, type, out_params)); + REQUIRE(type == RequestTypeOpcode::Triggered); + REQUIRE(out_params == params); +} + +// ── Request categories & execution-priority ordering ───────────────────────── + +TEST_CASE("category_of maps every opcode to its documented category", "[sequencer][REQ-SEQ-002]") { + REQUIRE(category_of(std::nullopt) == RequestCategory::Standard); + REQUIRE(category_of(RequestTypeOpcode::ClearAll) == RequestCategory::Cancellation); + REQUIRE(category_of(RequestTypeOpcode::ClearNonSafestate) == RequestCategory::Cancellation); + REQUIRE(category_of(RequestTypeOpcode::ClearSingle) == RequestCategory::Cancellation); + REQUIRE(category_of(RequestTypeOpcode::Triggered) == RequestCategory::Triggered); + REQUIRE(category_of(RequestTypeOpcode::Timed) == RequestCategory::Timed); + REQUIRE(category_of(RequestTypeOpcode::Compound) == RequestCategory::Compound); + REQUIRE(category_of(RequestTypeOpcode::CompoundWait) == RequestCategory::CompoundWait); + REQUIRE(category_of(RequestTypeOpcode::Chained) == RequestCategory::Chained); +} + +TEST_CASE("priority_rank orders categories cancellation..standard", "[sequencer][REQ-SEQ-002]") { + REQUIRE(priority_rank(RequestCategory::Cancellation) < priority_rank(RequestCategory::Triggered)); + REQUIRE(priority_rank(RequestCategory::Triggered) < priority_rank(RequestCategory::Timed)); + REQUIRE(priority_rank(RequestCategory::Timed) < priority_rank(RequestCategory::Compound)); + REQUIRE(priority_rank(RequestCategory::Compound) < priority_rank(RequestCategory::CompoundWait)); + REQUIRE(priority_rank(RequestCategory::CompoundWait) < priority_rank(RequestCategory::Chained)); + REQUIRE(priority_rank(RequestCategory::Chained) < priority_rank(RequestCategory::Standard)); +} + +TEST_CASE("select_next_due picks the highest-priority category regardless of arrival order", + "[sequencer][REQ-SEQ-002]") { + std::vector due{ + {RequestCategory::Standard, 0}, + {RequestCategory::Chained, 1}, + {RequestCategory::Cancellation, 2}, // arrived last, but highest priority + }; + auto winner = select_next_due(due); + REQUIRE(winner.has_value()); + REQUIRE(*winner == 2); +} + +TEST_CASE("select_next_due breaks ties within a category by FIFO arrival order", "[sequencer][REQ-SEQ-002]") { + std::vector due{ + {RequestCategory::Timed, 5}, + {RequestCategory::Timed, 2}, + {RequestCategory::Timed, 9}, + }; + auto winner = select_next_due(due); + REQUIRE(winner.has_value()); + REQUIRE(*winner == 1); // arrival_seq 2 is earliest +} + +TEST_CASE("select_next_due returns nullopt for an empty candidate set", "[sequencer][REQ-SEQ-002]") { + REQUIRE_FALSE(select_next_due({}).has_value()); +} + +// ── Optional-feature bundling ───────────────────────────────────────────────── + +TEST_CASE("validate_feature_bundles accepts a feature set with nothing conditional enabled", + "[sequencer][REQ-SEQ-003]") { + FeatureSet f; + REQUIRE_FALSE(validate_feature_bundles(f)); +} + +TEST_CASE("validate_feature_bundles accepts triggered/chained/timed independently of compound", + "[sequencer][REQ-SEQ-003]") { + FeatureSet f; + f.triggered = true; + f.chained = true; + f.timed = true; + REQUIRE_FALSE(validate_feature_bundles(f)); +} + +TEST_CASE("validate_feature_bundles rejects compound claimed alone", "[sequencer][REQ-SEQ-003]") { + FeatureSet f; + f.compound = true; // missing compound_wait, clear_non_safestate, and sequencer_count + REQUIRE(validate_feature_bundles(f) == make_error_code(SequencerErrc::compound_bundle_incomplete)); +} + +TEST_CASE("validate_feature_bundles rejects compound_wait claimed alone", "[sequencer][REQ-SEQ-003]") { + FeatureSet f; + f.compound_wait = true; + REQUIRE(validate_feature_bundles(f) == make_error_code(SequencerErrc::compound_bundle_incomplete)); +} + +TEST_CASE("validate_feature_bundles rejects compound+compound_wait with fewer than 4 sequencers", + "[sequencer][REQ-SEQ-003]") { + FeatureSet f; + f.compound = true; + f.compound_wait = true; + f.clear_non_safestate = true; + f.sequencer_count = kMinCompoundSequencers - 1; + REQUIRE(validate_feature_bundles(f) == make_error_code(SequencerErrc::compound_bundle_incomplete)); +} + +TEST_CASE("validate_feature_bundles rejects compound+compound_wait without clear_non_safestate", + "[sequencer][REQ-SEQ-003]") { + FeatureSet f; + f.compound = true; + f.compound_wait = true; + f.sequencer_count = kMinCompoundSequencers; + REQUIRE(validate_feature_bundles(f) == make_error_code(SequencerErrc::compound_bundle_incomplete)); +} + +TEST_CASE("validate_feature_bundles accepts the complete compound bundle", "[sequencer][REQ-SEQ-003]") { + FeatureSet f; + f.compound = true; + f.compound_wait = true; + f.clear_non_safestate = true; + f.sequencer_count = kMinCompoundSequencers; + REQUIRE_FALSE(validate_feature_bundles(f)); +} + +TEST_CASE("implemented_options_bits reports kOptConditionalRequests only when something is enabled", + "[sequencer][REQ-SEQ-003]") { + FeatureSet none; + REQUIRE(implemented_options_bits(none) == 0); + + FeatureSet triggered_only; + triggered_only.triggered = true; + REQUIRE(implemented_options_bits(triggered_only) == rcp::regmap::kOptConditionalRequests); +} + +// ── Sequencer-state registers ───────────────────────────────────────────────── + +TEST_CASE("SequencerTable::ensure_size fills new slots with kDefaultState (1), not 0", + "[sequencer][REQ-SEQ-004]") { + std::vector states; + SequencerTable table(states); + table.ensure_size(4); + REQUIRE(table.size() == 4); + for (size_t i = 0; i < 4; ++i) { + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(i, s)); + REQUIRE(s == SequencerTable::kDefaultState); + } +} + +TEST_CASE("SequencerTable::ensure_size never shrinks existing storage", "[sequencer][REQ-SEQ-004]") { + std::vector states{5, 5, 5}; + SequencerTable table(states); + table.ensure_size(1); + REQUIRE(table.size() == 3); +} + +TEST_CASE("SequencerTable::try_advance advances only when current state matches expected_start", + "[sequencer][REQ-SEQ-004]") { + std::vector states{SequencerTable::kDefaultState}; + SequencerTable table(states); + + bool advanced = false; + auto ec = table.try_advance(0, SequencerTable::kDefaultState, advanced); + REQUIRE_FALSE(ec); + REQUIRE(advanced); + + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == SequencerTable::kDefaultState + 1); +} + +TEST_CASE("SequencerTable::try_advance leaves a mismatched sequencer untouched", "[sequencer][REQ-SEQ-004]") { + std::vector states{9}; + SequencerTable table(states); + + bool advanced = true; + auto ec = table.try_advance(0, /*expected_start=*/1, advanced); + REQUIRE_FALSE(ec); // not an error — just didn't advance + REQUIRE_FALSE(advanced); + + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 9); +} + +TEST_CASE("SequencerTable reports index_out_of_range for an out-of-bounds index", "[sequencer][REQ-SEQ-004]") { + std::vector states{1}; + SequencerTable table(states); + rcp::regmap::SequencerState s = 0; + REQUIRE(table.state_of(5, s) == make_error_code(SequencerErrc::index_out_of_range)); + bool advanced = false; + REQUIRE(table.try_advance(5, 1, advanced) == make_error_code(SequencerErrc::index_out_of_range)); +} + +TEST_CASE("SequencerTable can be constructed directly over a RegisterMap's sequencer_states", + "[sequencer][REQ-SEQ-004]") { + rcp::regmap::RegisterMap regs; + SequencerTable table(regs); + table.ensure_size(4); + REQUIRE(regs.sequencer_states.size() == 4); + REQUIRE(regs.sequencer_states[0] == SequencerTable::kDefaultState); +} + +// ── Cancellation kinds & shared cancellation semantics ──────────────────────── + +TEST_CASE("RequestLedger::cancel_single cancels a pending request", "[sequencer][REQ-SEQ-005]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + + REQUIRE_FALSE(ledger.cancel_single(1)); + REQUIRE(ledger.find(1)->state == RequestState::Canceled); + + std::error_code outcome; + REQUIRE_FALSE(ledger.outcome_of(1, outcome)); + REQUIRE(outcome == make_error_code(SequencerErrc::request_canceled)); +} + +TEST_CASE("RequestLedger::cancel_single reports REQUEST_NOT_FOUND for an unknown transaction", + "[sequencer][REQ-SEQ-005]") { + RequestLedger ledger; + REQUIRE(ledger.cancel_single(77) == make_error_code(SequencerErrc::request_not_found)); +} + +TEST_CASE("RequestLedger::cancel_single leaves an already-executing request to finish", + "[sequencer][REQ-SEQ-005]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + + REQUIRE(ledger.cancel_single(1) == make_error_code(SequencerErrc::request_not_found)); + REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); +} + +TEST_CASE("Cancelling a chained request cancels its successors", "[sequencer][REQ-SEQ-005]") { + RequestLedger ledger; + RequestRecord a; + a.transaction_num = 1; + a.chained_successors = {2}; + RequestRecord b; + b.transaction_num = 2; + b.chained_predecessor = uint8_t{1}; + b.chained_successors = {3}; + RequestRecord c; + c.transaction_num = 3; + c.chained_predecessor = uint8_t{2}; + + REQUIRE_FALSE(ledger.submit(a)); + REQUIRE_FALSE(ledger.submit(b)); + REQUIRE_FALSE(ledger.submit(c)); + + REQUIRE_FALSE(ledger.cancel_single(1)); + REQUIRE(ledger.find(1)->state == RequestState::Canceled); + REQUIRE(ledger.find(2)->state == RequestState::Canceled); + REQUIRE(ledger.find(3)->state == RequestState::Canceled); +} + +TEST_CASE("cancel_all(clear-all) cancels every pending/started request", "[sequencer][REQ-SEQ-005]") { + RequestLedger ledger; + for (uint8_t i = 1; i <= 3; ++i) { + RequestRecord rec; + rec.transaction_num = i; + REQUIRE_FALSE(ledger.submit(rec)); + } + REQUIRE(ledger.cancel_all(/*non_safestate_only=*/false) == 3); + for (uint8_t i = 1; i <= 3; ++i) REQUIRE(ledger.find(i)->state == RequestState::Canceled); +} + +TEST_CASE("cancel_all(clear-non-safestate) skips is_safety records", "[sequencer][REQ-SEQ-005]") { + RequestLedger ledger; + RequestRecord normal; + normal.transaction_num = 1; + RequestRecord safety; + safety.transaction_num = 2; + safety.is_safety = true; + REQUIRE_FALSE(ledger.submit(normal)); + REQUIRE_FALSE(ledger.submit(safety)); + + REQUIRE(ledger.cancel_all(/*non_safestate_only=*/true) == 1); + REQUIRE(ledger.find(1)->state == RequestState::Canceled); + REQUIRE(ledger.find(2)->state == RequestState::Pending); +} + +TEST_CASE("cancel_all does not disturb an already-executing request", "[sequencer][REQ-SEQ-005]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + + REQUIRE(ledger.cancel_all(false) == 0); + REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); +} + +// ── Request lifecycle state machine ─────────────────────────────────────────── + +TEST_CASE("RequestLedger enforces the forward-only pending->started->under_execution->finalized sequence", + "[sequencer][REQ-SEQ-006]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE(ledger.find(1)->state == RequestState::Pending); + + REQUIRE_FALSE(ledger.start(1)); + REQUIRE(ledger.find(1)->state == RequestState::Started); + + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); + + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/false)); + REQUIRE(ledger.find(1)->state == RequestState::Finalized); +} + +TEST_CASE("RequestLedger rejects skipping a lifecycle step", "[sequencer][REQ-SEQ-006]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + + REQUIRE(ledger.begin_execution(1) == make_error_code(SequencerErrc::invalid_lifecycle_transition)); + REQUIRE(ledger.finalize(1, false) == make_error_code(SequencerErrc::invalid_lifecycle_transition)); +} + +TEST_CASE("RequestLedger::submit rejects a duplicate transaction_num", "[sequencer][REQ-SEQ-006]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE(ledger.submit(rec) == make_error_code(SequencerErrc::transaction_num_collision)); +} + +TEST_CASE("RequestLedger reports unknown_transaction for an untracked id", "[sequencer][REQ-SEQ-006]") { + RequestLedger ledger; + REQUIRE(ledger.start(99) == make_error_code(SequencerErrc::unknown_transaction)); +} + +TEST_CASE("finalize advances a compound request's sequencer on successful finalization", + "[sequencer][REQ-SEQ-006]") { + std::vector states{SequencerTable::kDefaultState}; + SequencerTable table(states); + + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + rec.request_type = RequestTypeOpcode::Compound; + rec.sequencer_index = size_t{0}; + rec.expected_start_state = SequencerTable::kDefaultState; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, false, &table)); + + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == SequencerTable::kDefaultState + 1); +} + +TEST_CASE("finalize does not advance the sequencer when its state no longer matches expected_start", + "[sequencer][REQ-SEQ-006]") { + std::vector states{99}; // not the expected start state + SequencerTable table(states); + + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + rec.request_type = RequestTypeOpcode::CompoundWait; + rec.sequencer_index = size_t{0}; + rec.expected_start_state = SequencerTable::kDefaultState; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, false, &table)); + + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 99); // untouched +} + +// ── Chained-successor propagation on predecessor finalization ──────────────── + +TEST_CASE("finalize with errored=true aborts a chained successor whose cs forbids executing anyway", + "[sequencer][REQ-SEQ-007]") { + RequestLedger ledger; + RequestRecord predecessor; + predecessor.transaction_num = 1; + predecessor.chained_successors = {2}; + RequestRecord successor; + successor.transaction_num = 2; + successor.chained_predecessor = uint8_t{1}; + successor.cs = false; // abort-on-predecessor-error + + REQUIRE_FALSE(ledger.submit(predecessor)); + REQUIRE_FALSE(ledger.submit(successor)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/true)); + + REQUIRE(ledger.find(2)->state == RequestState::Canceled); +} + +TEST_CASE("finalize with errored=true does not abort a chained successor with cs=execute-regardless", + "[sequencer][REQ-SEQ-007]") { + RequestLedger ledger; + RequestRecord predecessor; + predecessor.transaction_num = 1; + predecessor.chained_successors = {2}; + RequestRecord successor; + successor.transaction_num = 2; + successor.chained_predecessor = uint8_t{1}; + successor.cs = true; // execute regardless + + REQUIRE_FALSE(ledger.submit(predecessor)); + REQUIRE_FALSE(ledger.submit(successor)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/true)); + + REQUIRE(ledger.find(2)->state == RequestState::Pending); +} + +TEST_CASE("finalize with errored=false never aborts a chained successor regardless of its cs", + "[sequencer][REQ-SEQ-007]") { + RequestLedger ledger; + RequestRecord predecessor; + predecessor.transaction_num = 1; + predecessor.chained_successors = {2}; + RequestRecord successor; + successor.transaction_num = 2; + successor.chained_predecessor = uint8_t{1}; + successor.cs = false; + + REQUIRE_FALSE(ledger.submit(predecessor)); + REQUIRE_FALSE(ledger.submit(successor)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/false)); + + REQUIRE(ledger.find(2)->state == RequestState::Pending); +} + +// ── The `cs` field's dual meaning ───────────────────────────────────────────── + +TEST_CASE("compound_wait_check_of maps cs to Immediate vs AfterChangeOnly", "[sequencer][REQ-SEQ-008]") { + REQUIRE(compound_wait_check_of(true) == CompoundWaitCheck::Immediate); + REQUIRE(compound_wait_check_of(false) == CompoundWaitCheck::AfterChangeOnly); +} + +TEST_CASE("should_execute_chained: cs=true always executes regardless of predecessor outcome", + "[sequencer][REQ-SEQ-008]") { + REQUIRE(should_execute_chained(/*cs=*/true, /*predecessor_errored=*/true)); + REQUIRE(should_execute_chained(/*cs=*/true, /*predecessor_errored=*/false)); +} + +TEST_CASE("should_execute_chained: cs=false aborts only when the predecessor errored", + "[sequencer][REQ-SEQ-008]") { + REQUIRE_FALSE(should_execute_chained(/*cs=*/false, /*predecessor_errored=*/true)); + REQUIRE(should_execute_chained(/*cs=*/false, /*predecessor_errored=*/false)); +} + +// ── SequencerErrc category sanity ───────────────────────────────────────────── + +TEST_CASE("SequencerErrc reports a non-empty message in its own category", "[sequencer][REQ-SEQ-009]") { + auto ec = make_error_code(SequencerErrc::request_canceled); + REQUIRE(ec.category() == sequencer_category()); + REQUIRE_FALSE(ec.message().empty()); +} + +TEST_CASE("every SequencerErrc value has a distinct, non-empty message", "[sequencer][REQ-SEQ-009]") { + const SequencerErrc all[] = { + SequencerErrc::timestamp_not_repurposed, SequencerErrc::unknown_request_type, + SequencerErrc::index_out_of_range, SequencerErrc::unknown_transaction, + SequencerErrc::invalid_lifecycle_transition, SequencerErrc::transaction_num_collision, + SequencerErrc::request_not_found, SequencerErrc::request_canceled, + SequencerErrc::compound_bundle_incomplete, + }; + std::vector messages; + for (auto e : all) { + auto ec = make_error_code(e); + REQUIRE_FALSE(ec.message().empty()); + messages.push_back(ec.message()); + } + for (size_t i = 0; i < messages.size(); ++i) + for (size_t j = i + 1; j < messages.size(); ++j) + REQUIRE(messages[i] != messages[j]); +}