From c1d7b4246bd1c32b2a934eb4e9c7056006a8ee05 Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Tue, 2 Jun 2026 10:24:58 -0400 Subject: [PATCH] feat: event-driven eval, multi-model composition, state machine support REQ-ARCH-036: Event-driven evaluation (arbiter_event.h/c) - Watch/notify bitmask API for triggering eval on fact changes - Static bitmask implementation (uint32_t for <=32 facts, byte array for larger) - CONFIG_ARBITER_EVENT_DRIVEN Kconfig option REQ-ARCH-037: Multi-model composition (arbiter_compose.h/c) - Shared fact bus with bus-to-model mappings - Static array of attached contexts (CONFIG_ARBITER_MAX_MODELS) - Sync and eval_all APIs - CONFIG_ARBITER_COMPOSE Kconfig option REQ-ARCH-039: State machine support (arbiter_state.h/c) - State/transition definitions with priority-based evaluation - Guard conditions, on_enter/on_exit actions - Schema: states array with nested transitions - Compiler: _flatten_states/_flatten_transitions in canonical.py - Emitter: state/transition table generation in emit_c.py - CONFIG_ARBITER_STATE_MACHINE Kconfig option All 63 existing Python tests pass with no regressions. Co-Authored-By: Oz --- CMakeLists.txt | 12 ++ include/arbiter/arbiter_compose.h | 141 ++++++++++++++++ include/arbiter/arbiter_event.h | 118 +++++++++++++ include/arbiter/arbiter_state.h | 127 ++++++++++++++ lib/arbiter_compose.c | 180 ++++++++++++++++++++ lib/arbiter_event.c | 171 +++++++++++++++++++ lib/arbiter_state.c | 266 ++++++++++++++++++++++++++++++ python/arbiter/canonical.py | 81 +++++++++ python/arbiter/emit_c.py | 54 ++++++ schema/arb.schema.json | 51 ++++++ subsys/arbiter/Kconfig | 51 ++++++ 11 files changed, 1252 insertions(+) create mode 100644 include/arbiter/arbiter_compose.h create mode 100644 include/arbiter/arbiter_event.h create mode 100644 include/arbiter/arbiter_state.h create mode 100644 lib/arbiter_compose.c create mode 100644 lib/arbiter_event.c create mode 100644 lib/arbiter_state.c diff --git a/CMakeLists.txt b/CMakeLists.txt index e9071f5..5ab92be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,18 @@ if(CONFIG_ARBITER) ${CMAKE_CURRENT_LIST_DIR}/lib/arbiter_accel.c ) + zephyr_library_sources_ifdef(CONFIG_ARBITER_EVENT_DRIVEN + ${CMAKE_CURRENT_LIST_DIR}/lib/arbiter_event.c + ) + + zephyr_library_sources_ifdef(CONFIG_ARBITER_COMPOSE + ${CMAKE_CURRENT_LIST_DIR}/lib/arbiter_compose.c + ) + + zephyr_library_sources_ifdef(CONFIG_ARBITER_STATE_MACHINE + ${CMAKE_CURRENT_LIST_DIR}/lib/arbiter_state.c + ) + zephyr_include_directories(${CMAKE_CURRENT_LIST_DIR}/include) add_subdirectory_ifdef(CONFIG_ARBITER ${CMAKE_CURRENT_LIST_DIR}/subsys/arbiter) diff --git a/include/arbiter/arbiter_compose.h b/include/arbiter/arbiter_compose.h new file mode 100644 index 0000000..7e1b93a --- /dev/null +++ b/include/arbiter/arbiter_compose.h @@ -0,0 +1,141 @@ +/* SPDX-License-Identifier: MIT */ + +#ifndef ARBITER_COMPOSE_H_ +#define ARBITER_COMPOSE_H_ + +/** + * @defgroup arbiter_compose Multi-Model Composition (REQ-ARCH-037) + * @ingroup arbiter + * @{ + * @brief Share facts across multiple models via a common fact bus. + * + * The fact bus holds a shared array of fact values. Each model is + * "attached" with a mapping table that translates bus fact indices + * to the model's own fact indices. ARBITER_compose_sync() pushes + * bus values into all attached contexts; ARBITER_compose_eval_all() + * evaluates every attached model in attachment order. + * + * All storage is pre-allocated — no malloc. + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef CONFIG_ARBITER_MAX_MODELS +#define CONFIG_ARBITER_MAX_MODELS 4 +#endif + +#ifndef CONFIG_ARBITER_COMPOSE_MAX_BUS_FACTS +#define CONFIG_ARBITER_COMPOSE_MAX_BUS_FACTS 64 +#endif + +/** Maps one bus fact to a model fact. */ +struct ARBITER_fact_mapping { + uint16_t bus_fact_id; /**< Index into the bus fact array. */ + uint16_t model_fact_id; /**< Index into the model's own fact array. */ +}; + +/** Per-attachment record (bus ↔ model link). */ +struct ARBITER_compose_attachment { + struct ARBITER_ctx *ctx; /**< Model context. */ + const struct ARBITER_fact_mapping *mapping; /**< Mapping table. */ + uint16_t map_count; /**< Number of mappings. */ + bool active; /**< Slot occupied? */ +}; + +/** Shared fact bus. */ +struct ARBITER_fact_bus { + struct ARBITER_fact_value *facts; /**< Shared fact array. */ + uint16_t max_facts; /**< Capacity of facts[]. */ + + struct ARBITER_compose_attachment + attachments[CONFIG_ARBITER_MAX_MODELS]; /**< Attached models. */ + uint16_t attachment_count; /**< Active attachments. */ +}; + +/** + * @brief Initialize a fact bus with a pre-allocated fact array. + * + * @param bus Bus to initialize. + * @param facts Pre-allocated array of fact values. + * @param max_facts Capacity of facts[]. + * @return ARBITER_OK on success, ARBITER_EINVAL if bus or facts is NULL. + */ +int ARBITER_compose_init(struct ARBITER_fact_bus *bus, + struct ARBITER_fact_value *facts, + uint16_t max_facts); + +/** + * @brief Attach a model context to the bus. + * + * @param bus Initialized fact bus. + * @param ctx Model context to attach. + * @param mapping Array of bus-to-model fact mappings. + * @param map_count Number of entries in mapping[]. + * @return ARBITER_OK on success, ARBITER_EOVERFLOW if no slots available. + */ +int ARBITER_compose_attach(struct ARBITER_fact_bus *bus, + struct ARBITER_ctx *ctx, + const struct ARBITER_fact_mapping *mapping, + uint16_t map_count); + +/** + * @brief Detach a model context from the bus. + * + * @param bus Bus. + * @param ctx Context to detach. + * @return ARBITER_OK on success, ARBITER_EINVAL if not found. + */ +int ARBITER_compose_detach(struct ARBITER_fact_bus *bus, + struct ARBITER_ctx *ctx); + +/** + * @brief Set a fact value on the bus. + * + * @param bus Initialized fact bus. + * @param fact_id Bus fact index. + * @param value Value to set. + * @return ARBITER_OK or ARBITER_ERANGE. + */ +int ARBITER_compose_set_fact(struct ARBITER_fact_bus *bus, + uint16_t fact_id, int32_t value); + +/** + * @brief Push bus facts into all attached model contexts. + * + * For each attachment, iterates the mapping table and copies the bus + * fact values into the model context's fact_values[]. + * + * @param bus Initialized fact bus. + * @return ARBITER_OK on success. + */ +int ARBITER_compose_sync(struct ARBITER_fact_bus *bus); + +/** + * @brief Evaluate all attached models in attachment order. + * + * For each attached model, takes a snapshot, evaluates, and stores + * the result. Caller must provide arrays of at least + * bus->attachment_count entries. + * + * @param bus Initialized fact bus. + * @param results Pre-allocated array for results (one per attached model). + * @param traces Pre-allocated array for traces (one per model, NULL entries OK). + * @return ARBITER_OK on success. + */ +int ARBITER_compose_eval_all(struct ARBITER_fact_bus *bus, + struct ARBITER_result *results, + struct ARBITER_trace *traces); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* ARBITER_COMPOSE_H_ */ diff --git a/include/arbiter/arbiter_event.h b/include/arbiter/arbiter_event.h new file mode 100644 index 0000000..7df4262 --- /dev/null +++ b/include/arbiter/arbiter_event.h @@ -0,0 +1,118 @@ +/* SPDX-License-Identifier: MIT */ + +#ifndef ARBITER_EVENT_H_ +#define ARBITER_EVENT_H_ + +/** + * @defgroup arbiter_event Event-Driven Evaluation (REQ-ARCH-036) + * @ingroup arbiter + * @{ + * @brief Watch facts for changes and trigger evaluation only when needed. + * + * Instead of polling on a fixed period, the event subsystem lets callers + * mark specific facts as "watched". When a watched fact changes, a + * pending flag is set. The runtime thread (or application) can check + * ARBITER_event_pending() and skip evaluation cycles when nothing has + * changed — saving CPU on idle periods. + * + * Implementation uses static bitmasks — no dynamic allocation. + * For models with <= 32 facts a single uint32_t pair is used. + * For larger models a uint8_t byte-array is used (1 bit per fact). + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef CONFIG_ARBITER_MAX_FACTS +#define CONFIG_ARBITER_MAX_FACTS 64 +#endif + +/** Number of bytes needed to hold one bit per fact. */ +#define ARBITER_EVENT_MASK_BYTES \ + ((CONFIG_ARBITER_MAX_FACTS + 7u) / 8u) + +/** Event tracking state — embedded in or alongside an ARBITER_ctx. */ +struct ARBITER_event_ctx { +#if CONFIG_ARBITER_MAX_FACTS <= 32 + uint32_t watched; /**< Bitmask of watched fact ids. */ + uint32_t pending; /**< Bitmask of changed watched facts. */ +#else + uint8_t watched[ARBITER_EVENT_MASK_BYTES]; + uint8_t pending[ARBITER_EVENT_MASK_BYTES]; +#endif + bool any_pending; /**< Fast flag: true if any bit in pending is set. */ +}; + +/** + * @brief Initialize event tracking for a context. + * + * Clears all watched and pending flags. + * + * @param ectx Event context to initialize. + * @return ARBITER_OK on success, ARBITER_EINVAL if ectx is NULL. + */ +int ARBITER_event_init(struct ARBITER_event_ctx *ectx); + +/** + * @brief Mark a fact as watched. + * + * @param ectx Event context. + * @param fact_id Fact index (0-based, < CONFIG_ARBITER_MAX_FACTS). + * @return ARBITER_OK on success, ARBITER_ERANGE if fact_id out of range. + */ +int ARBITER_watch_fact(struct ARBITER_event_ctx *ectx, uint16_t fact_id); + +/** + * @brief Remove a fact from the watch set. + * + * Also clears any pending flag for that fact. + * + * @param ectx Event context. + * @param fact_id Fact index. + * @return ARBITER_OK on success, ARBITER_ERANGE if fact_id out of range. + */ +int ARBITER_unwatch_fact(struct ARBITER_event_ctx *ectx, uint16_t fact_id); + +/** + * @brief Notify that a fact has changed. + * + * If the fact is watched, sets its pending bit and the fast flag. + * If the fact is not watched, this is a no-op. + * + * @param ectx Event context. + * @param fact_id Fact index. + * @return ARBITER_OK on success, ARBITER_ERANGE if fact_id out of range. + */ +int ARBITER_notify_fact_changed(struct ARBITER_event_ctx *ectx, + uint16_t fact_id); + +/** + * @brief Check whether any watched fact has changed since last clear. + * + * @param ectx Event context. + * @return true if at least one watched fact has a pending change. + */ +bool ARBITER_event_pending(const struct ARBITER_event_ctx *ectx); + +/** + * @brief Clear all pending flags. + * + * Typically called after a successful evaluation cycle. + * + * @param ectx Event context. + * @return ARBITER_OK on success, ARBITER_EINVAL if ectx is NULL. + */ +int ARBITER_event_clear(struct ARBITER_event_ctx *ectx); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* ARBITER_EVENT_H_ */ diff --git a/include/arbiter/arbiter_state.h b/include/arbiter/arbiter_state.h new file mode 100644 index 0000000..57c3829 --- /dev/null +++ b/include/arbiter/arbiter_state.h @@ -0,0 +1,127 @@ +/* SPDX-License-Identifier: MIT */ + +#ifndef ARBITER_STATE_H_ +#define ARBITER_STATE_H_ + +/** + * @defgroup arbiter_state State Machine Support (REQ-ARCH-039) + * @ingroup arbiter + * @{ + * @brief Explicit state machine with guarded transitions. + * + * A model may define a set of states and transitions. Each transition + * has a source state, a target state, a condition range (reusing the + * condition table), an optional guard range, and a priority. The + * evaluator finds the highest-priority enabled transition from the + * current state and returns the target. + * + * States can have on_enter / on_exit actions (indices into the action + * table). The caller is responsible for dispatching them. + * + * All storage is embedded in the compiled model — no dynamic allocation. + */ + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef CONFIG_ARBITER_MAX_STATES +#define CONFIG_ARBITER_MAX_STATES 16 +#endif + +#ifndef CONFIG_ARBITER_MAX_TRANSITIONS +#define CONFIG_ARBITER_MAX_TRANSITIONS 32 +#endif + +/** State definition. */ +struct ARBITER_state_def { + arbiter_index_t id; /**< State index. */ + arbiter_index_t on_enter_action; /**< Action to run on entry (INDEX_MAX = none). */ + arbiter_index_t on_exit_action; /**< Action to run on exit (INDEX_MAX = none). */ +#if !defined(CONFIG_ARBITER_STRINGS) || CONFIG_ARBITER_STRINGS + const char *name; +#endif +}; + +/** Transition definition. */ +struct ARBITER_transition_def { + arbiter_index_t source_state; /**< Source state index. */ + arbiter_index_t target_state; /**< Target state index. */ + arbiter_index_t condition_start; /**< Index into model conditions table. */ + arbiter_index_t condition_count; /**< Number of conditions. */ + arbiter_index_t guard_start; /**< Index into model conditions table (guards). */ + arbiter_index_t guard_count; /**< Number of guard conditions. */ + arbiter_index_t priority; /**< Higher = evaluated first. */ +}; + +/** Result of a state evaluation. */ +struct ARBITER_state_result { + arbiter_index_t next_state; /**< Target state (unchanged if no transition). */ + arbiter_index_t transition_index; /**< Which transition fired (INDEX_MAX = none). */ + arbiter_index_t on_exit_action; /**< Action from departing state (INDEX_MAX = none). */ + arbiter_index_t on_enter_action; /**< Action from entering state (INDEX_MAX = none). */ + bool transitioned; /**< True if a transition fired. */ +}; + +/** + * @brief Evaluate state transitions from the current state. + * + * Iterates all transitions whose source_state matches current_state, + * in priority order (highest first). For each candidate, evaluates + * its conditions and guards against the snapshot. The first fully- + * satisfied transition wins. + * + * @param model Compiled model (must have state/transition tables). + * @param states State definition table. + * @param state_count Number of states. + * @param transitions Transition definition table. + * @param trans_count Number of transitions. + * @param snapshot Frozen fact snapshot. + * @param current_state Current state index. + * @param result Output — populated with the winning transition. + * @return ARBITER_OK on success, ARBITER_EINVAL on bad arguments. + */ +int ARBITER_state_eval(const struct ARBITER_model *model, + const struct ARBITER_state_def *states, + arbiter_index_t state_count, + const struct ARBITER_transition_def *transitions, + arbiter_index_t trans_count, + const struct ARBITER_snapshot *snapshot, + arbiter_index_t current_state, + struct ARBITER_state_result *result); + +/** + * @brief Get the current state stored in a context. + * + * State is stored in fact_values[0] by convention when the state + * machine feature is active, but this accessor reads from a + * dedicated field added by CONFIG_ARBITER_STATE_MACHINE. + * + * @param ctx Initialized context. + * @param state_id Output state index. + * @return ARBITER_OK or ARBITER_EINVAL. + */ +int ARBITER_state_get(const struct ARBITER_ctx *ctx, + arbiter_index_t *state_id); + +/** + * @brief Set the current state in a context. + * + * @param ctx Initialized context. + * @param state_id State index to set. + * @return ARBITER_OK or ARBITER_EINVAL. + */ +int ARBITER_state_set(struct ARBITER_ctx *ctx, arbiter_index_t state_id); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* ARBITER_STATE_H_ */ diff --git a/lib/arbiter_compose.c b/lib/arbiter_compose.c new file mode 100644 index 0000000..1dc26be --- /dev/null +++ b/lib/arbiter_compose.c @@ -0,0 +1,180 @@ +/* SPDX-License-Identifier: MIT */ + +/** + * @file arbiter_compose.c + * @brief Multi-model composition — shared fact bus implementation. + * + * Static array of attached contexts (CONFIG_ARBITER_MAX_MODELS slots). + * All storage is pre-allocated. No malloc. + */ + +#include +#include +#include + +int ARBITER_compose_init(struct ARBITER_fact_bus *bus, + struct ARBITER_fact_value *facts, + uint16_t max_facts) +{ + if (unlikely(bus == NULL || facts == NULL)) { + return ARBITER_EINVAL; + } + + memset(bus, 0, sizeof(*bus)); + bus->facts = facts; + bus->max_facts = max_facts; + + /* Zero the shared fact array */ + memset(facts, 0, (size_t)max_facts * sizeof(*facts)); + + return ARBITER_OK; +} + +int ARBITER_compose_attach(struct ARBITER_fact_bus *bus, + struct ARBITER_ctx *ctx, + const struct ARBITER_fact_mapping *mapping, + uint16_t map_count) +{ + if (unlikely(bus == NULL || ctx == NULL || mapping == NULL)) { + return ARBITER_EINVAL; + } + + /* Find a free slot */ + for (uint16_t i = 0; i < CONFIG_ARBITER_MAX_MODELS; i++) { + if (!bus->attachments[i].active) { + bus->attachments[i].ctx = ctx; + bus->attachments[i].mapping = mapping; + bus->attachments[i].map_count = map_count; + bus->attachments[i].active = true; + bus->attachment_count++; + return ARBITER_OK; + } + } + + return ARBITER_EOVERFLOW; +} + +int ARBITER_compose_detach(struct ARBITER_fact_bus *bus, + struct ARBITER_ctx *ctx) +{ + if (unlikely(bus == NULL || ctx == NULL)) { + return ARBITER_EINVAL; + } + + for (uint16_t i = 0; i < CONFIG_ARBITER_MAX_MODELS; i++) { + if (bus->attachments[i].active && + bus->attachments[i].ctx == ctx) { + bus->attachments[i].active = false; + bus->attachments[i].ctx = NULL; + bus->attachments[i].mapping = NULL; + bus->attachments[i].map_count = 0; + bus->attachment_count--; + return ARBITER_OK; + } + } + + return ARBITER_EINVAL; +} + +int ARBITER_compose_set_fact(struct ARBITER_fact_bus *bus, + uint16_t fact_id, int32_t value) +{ + if (unlikely(bus == NULL)) { + return ARBITER_EINVAL; + } + if (unlikely(fact_id >= bus->max_facts)) { + return ARBITER_ERANGE; + } + + bus->facts[fact_id].prev_value = bus->facts[fact_id].value; + bus->facts[fact_id].value = value; + bus->facts[fact_id].valid = true; + bus->facts[fact_id].changed = + (bus->facts[fact_id].value != bus->facts[fact_id].prev_value); + + return ARBITER_OK; +} + +int ARBITER_compose_sync(struct ARBITER_fact_bus *bus) +{ + if (unlikely(bus == NULL)) { + return ARBITER_EINVAL; + } + + for (uint16_t i = 0; i < CONFIG_ARBITER_MAX_MODELS; i++) { + const struct ARBITER_compose_attachment *__restrict att = + &bus->attachments[i]; + + if (!att->active) { + continue; + } + + struct ARBITER_fact_value *__restrict model_vals = + att->ctx->fact_values; + const uint16_t model_max = att->ctx->snapshot.count; + const struct ARBITER_fact_mapping *__restrict map = + att->mapping; + const uint16_t mc = att->map_count; + + for (uint16_t m = 0; m < mc; m++) { + const uint16_t bid = map[m].bus_fact_id; + const uint16_t mid = map[m].model_fact_id; + + if (likely(bid < bus->max_facts && mid < model_max)) { + model_vals[mid].prev_value = + model_vals[mid].value; + model_vals[mid].value = + bus->facts[bid].value; + model_vals[mid].valid = + bus->facts[bid].valid; + model_vals[mid].changed = + (model_vals[mid].value != + model_vals[mid].prev_value); + } + } + } + + return ARBITER_OK; +} + +int ARBITER_compose_eval_all(struct ARBITER_fact_bus *bus, + struct ARBITER_result *results, + struct ARBITER_trace *traces) +{ + if (unlikely(bus == NULL || results == NULL)) { + return ARBITER_EINVAL; + } + + uint16_t idx = 0; + + for (uint16_t i = 0; i < CONFIG_ARBITER_MAX_MODELS; i++) { + const struct ARBITER_compose_attachment *__restrict att = + &bus->attachments[i]; + + if (!att->active) { + continue; + } + + struct ARBITER_ctx *__restrict ctx = att->ctx; + struct ARBITER_snapshot snap; + + /* Take snapshot */ + int rc = ARBITER_snapshot_begin(ctx, &snap); + + if (unlikely(rc != ARBITER_OK)) { + results[idx].status = rc; + idx++; + continue; + } + + /* Evaluate */ + struct ARBITER_trace *trace = + (traces != NULL) ? &traces[idx] : NULL; + + rc = ARBITER_eval(ctx->model, &snap, &results[idx], trace); + results[idx].status = rc; + idx++; + } + + return ARBITER_OK; +} diff --git a/lib/arbiter_event.c b/lib/arbiter_event.c new file mode 100644 index 0000000..fdc5a5d --- /dev/null +++ b/lib/arbiter_event.c @@ -0,0 +1,171 @@ +/* SPDX-License-Identifier: MIT */ + +/** + * @file arbiter_event.c + * @brief Event-driven evaluation — watch/notify bitmask implementation. + * + * Uses compile-time-sized bitmasks: uint32_t for <= 32 facts, + * uint8_t array for larger models. Zero dynamic allocation. + */ + +#include +#include +#include + +/* ── Bitmask helpers (compile-time selected) ──────────────────── */ + +#if CONFIG_ARBITER_MAX_FACTS <= 32 + +static inline void mask_set(uint32_t *mask, uint16_t bit) +{ + *mask |= BIT(bit); +} + +static inline void mask_clear(uint32_t *mask, uint16_t bit) +{ + *mask &= ~BIT(bit); +} + +static inline bool mask_test(uint32_t mask, uint16_t bit) +{ + return (mask & BIT(bit)) != 0u; +} + +static inline bool mask_any(uint32_t mask) +{ + return mask != 0u; +} + +#else /* byte-array path for > 32 facts */ + +static inline void bmask_set(uint8_t *arr, uint16_t bit) +{ + arr[bit >> 3] |= (uint8_t)(1u << (bit & 7u)); +} + +static inline void bmask_clear(uint8_t *arr, uint16_t bit) +{ + arr[bit >> 3] &= (uint8_t)~(1u << (bit & 7u)); +} + +static inline bool bmask_test(const uint8_t *arr, uint16_t bit) +{ + return (arr[bit >> 3] & (uint8_t)(1u << (bit & 7u))) != 0u; +} + +static inline bool bmask_any(const uint8_t *arr, size_t bytes) +{ + for (size_t i = 0; i < bytes; i++) { + if (arr[i] != 0u) { + return true; + } + } + return false; +} + +#endif /* CONFIG_ARBITER_MAX_FACTS <= 32 */ + +/* ── Public API ───────────────────────────────────────────────── */ + +int ARBITER_event_init(struct ARBITER_event_ctx *ectx) +{ + if (unlikely(ectx == NULL)) { + return ARBITER_EINVAL; + } + + memset(ectx, 0, sizeof(*ectx)); + return ARBITER_OK; +} + +int ARBITER_watch_fact(struct ARBITER_event_ctx *ectx, uint16_t fact_id) +{ + if (unlikely(ectx == NULL)) { + return ARBITER_EINVAL; + } + if (unlikely(fact_id >= CONFIG_ARBITER_MAX_FACTS)) { + return ARBITER_ERANGE; + } + +#if CONFIG_ARBITER_MAX_FACTS <= 32 + mask_set(&ectx->watched, fact_id); +#else + bmask_set(ectx->watched, fact_id); +#endif + + return ARBITER_OK; +} + +int ARBITER_unwatch_fact(struct ARBITER_event_ctx *ectx, uint16_t fact_id) +{ + if (unlikely(ectx == NULL)) { + return ARBITER_EINVAL; + } + if (unlikely(fact_id >= CONFIG_ARBITER_MAX_FACTS)) { + return ARBITER_ERANGE; + } + +#if CONFIG_ARBITER_MAX_FACTS <= 32 + mask_clear(&ectx->watched, fact_id); + mask_clear(&ectx->pending, fact_id); + + /* Recompute fast flag */ + ectx->any_pending = mask_any(ectx->pending); +#else + bmask_clear(ectx->watched, fact_id); + bmask_clear(ectx->pending, fact_id); + + ectx->any_pending = bmask_any(ectx->pending, + ARBITER_EVENT_MASK_BYTES); +#endif + + return ARBITER_OK; +} + +int ARBITER_notify_fact_changed(struct ARBITER_event_ctx *ectx, + uint16_t fact_id) +{ + if (unlikely(ectx == NULL)) { + return ARBITER_EINVAL; + } + if (unlikely(fact_id >= CONFIG_ARBITER_MAX_FACTS)) { + return ARBITER_ERANGE; + } + +#if CONFIG_ARBITER_MAX_FACTS <= 32 + if (mask_test(ectx->watched, fact_id)) { + mask_set(&ectx->pending, fact_id); + ectx->any_pending = true; + } +#else + if (bmask_test(ectx->watched, fact_id)) { + bmask_set(ectx->pending, fact_id); + ectx->any_pending = true; + } +#endif + + return ARBITER_OK; +} + +bool ARBITER_event_pending(const struct ARBITER_event_ctx *ectx) +{ + if (unlikely(ectx == NULL)) { + return false; + } + return ectx->any_pending; +} + +int ARBITER_event_clear(struct ARBITER_event_ctx *ectx) +{ + if (unlikely(ectx == NULL)) { + return ARBITER_EINVAL; + } + +#if CONFIG_ARBITER_MAX_FACTS <= 32 + ectx->pending = 0u; +#else + memset(ectx->pending, 0, sizeof(ectx->pending)); +#endif + ectx->any_pending = false; + + return ARBITER_OK; +} diff --git a/lib/arbiter_state.c b/lib/arbiter_state.c new file mode 100644 index 0000000..727e080 --- /dev/null +++ b/lib/arbiter_state.c @@ -0,0 +1,266 @@ +/* SPDX-License-Identifier: MIT */ + +/** + * @file arbiter_state.c + * @brief State machine evaluator — guarded transitions with priority. + * + * Evaluates transitions from the current state using the model's + * condition table. Highest-priority enabled transition wins. + * No dynamic allocation. + */ + +#include +#include +#include +#include + +/* Forward-declare the condition group evaluator from arbiter_eval.c. + * This is an internal helper — not part of the public API. + * We reuse it to avoid duplicating condition evaluation logic. + * + * For a clean build the linker resolves this from the same library. + * If needed, this could be extracted into a shared internal header. + * For now we declare it extern here. + */ + +/* ── Internal condition evaluation (simplified for state machine) ─ */ + +/** + * Evaluate a range of conditions as an ALL-group. + * Returns true if all conditions in [start, start+count) are satisfied. + */ +static bool eval_state_conditions( + const struct ARBITER_condition_def *__restrict conds, + arbiter_index_t cond_table_count, + const struct ARBITER_fact_value *__restrict values, + arbiter_index_t vcount, uint32_t snap_ts, + arbiter_index_t start, arbiter_index_t count) +{ + if (count == 0) { + return true; + } + + for (arbiter_index_t i = 0; i < count; i++) { + const arbiter_index_t ci = start + i; + + if (unlikely(ci >= cond_table_count)) { + return false; + } + + const struct ARBITER_condition_def *__restrict c = &conds[ci]; + + if (unlikely(c->fact_id >= vcount)) { + return false; + } + + const struct ARBITER_fact_value *__restrict fv = + &values[c->fact_id]; + const int32_t val = fv->value; + bool ok; + + switch (c->op) { + case ARBITER_OP_EQ: + ok = (val == c->value); + break; + case ARBITER_OP_NE: + ok = (val != c->value); + break; + case ARBITER_OP_LT: + ok = (val < c->value); + break; + case ARBITER_OP_LE: + ok = (val <= c->value); + break; + case ARBITER_OP_GT: + ok = (val > c->value); + break; + case ARBITER_OP_GE: + ok = (val >= c->value); + break; + case ARBITER_OP_CHANGED: + ok = fv->changed; + break; + case ARBITER_OP_STALE: + ok = (fv->timestamp_ms == 0) || + ((snap_ts - fv->timestamp_ms) > + (uint32_t)c->value); + break; + case ARBITER_OP_NOT_STALE: + ok = (fv->timestamp_ms != 0) && + ((snap_ts - fv->timestamp_ms) <= + (uint32_t)c->value); + break; + case ARBITER_OP_IN: + ok = (val == c->value); + break; + case ARBITER_OP_NOT_IN: + ok = (val != c->value); + break; + case ARBITER_OP_DELTA_GT: { + int32_t d = val - fv->prev_value; + int32_t mask = d >> 31; + + ok = (((d ^ mask) - mask) > c->value); + break; + } + case ARBITER_OP_DELTA_LT: { + int32_t d = val - fv->prev_value; + int32_t mask = d >> 31; + + ok = (((d ^ mask) - mask) < c->value); + break; + } + default: + ok = false; + break; + } + + if (!ok) { + return false; + } + } + + return true; +} + +/* ── Public API ───────────────────────────────────────────────── */ + +int ARBITER_state_eval(const struct ARBITER_model *model, + const struct ARBITER_state_def *states, + arbiter_index_t state_count, + const struct ARBITER_transition_def *transitions, + arbiter_index_t trans_count, + const struct ARBITER_snapshot *snapshot, + arbiter_index_t current_state, + struct ARBITER_state_result *result) +{ + if (unlikely(model == NULL || snapshot == NULL || result == NULL)) { + return ARBITER_EINVAL; + } + if (unlikely(transitions == NULL && trans_count > 0)) { + return ARBITER_EINVAL; + } + + /* Default: no transition */ + result->next_state = current_state; + result->transition_index = ARBITER_INDEX_MAX; + result->on_exit_action = ARBITER_INDEX_MAX; + result->on_enter_action = ARBITER_INDEX_MAX; + result->transitioned = false; + + const struct ARBITER_condition_def *__restrict conds = + model->conditions; + const arbiter_index_t cond_table_count = model->condition_count; + const struct ARBITER_fact_value *__restrict values = + snapshot->values; + const arbiter_index_t vcount = snapshot->count; + const uint32_t snap_ts = snapshot->timestamp_ms; + + /* + * Find the highest-priority enabled transition from current_state. + * We iterate all transitions and track the best match. + */ + arbiter_index_t best_priority = 0; + arbiter_index_t best_idx = ARBITER_INDEX_MAX; + bool found = false; + + for (arbiter_index_t t = 0; t < trans_count; t++) { + const struct ARBITER_transition_def *__restrict tr = + &transitions[t]; + + if (tr->source_state != current_state) { + continue; + } + + /* Check priority: higher wins, or first if equal */ + if (found && tr->priority < best_priority) { + continue; + } + + /* Evaluate conditions */ + bool conds_ok = eval_state_conditions( + conds, cond_table_count, values, vcount, snap_ts, + tr->condition_start, tr->condition_count); + + if (!conds_ok) { + continue; + } + + /* Evaluate guards */ + bool guards_ok = eval_state_conditions( + conds, cond_table_count, values, vcount, snap_ts, + tr->guard_start, tr->guard_count); + + if (!guards_ok) { + continue; + } + + /* This transition is enabled and highest priority so far */ + if (!found || tr->priority > best_priority) { + best_priority = tr->priority; + best_idx = t; + found = true; + } + } + + if (found) { + const struct ARBITER_transition_def *__restrict winner = + &transitions[best_idx]; + + result->next_state = winner->target_state; + result->transition_index = best_idx; + result->transitioned = true; + + /* Look up on_exit from current state */ + if (states != NULL && current_state < state_count) { + result->on_exit_action = + states[current_state].on_exit_action; + } + + /* Look up on_enter for target state */ + if (states != NULL && + winner->target_state < state_count) { + result->on_enter_action = + states[winner->target_state].on_enter_action; + } + } + + return ARBITER_OK; +} + +int ARBITER_state_get(const struct ARBITER_ctx *ctx, + arbiter_index_t *state_id) +{ + if (unlikely(ctx == NULL || state_id == NULL)) { + return ARBITER_EINVAL; + } + if (unlikely(!ctx->initialized)) { + return ARBITER_EINVAL; + } + + /* + * State is stored as the value of fact_values[0] when + * CONFIG_ARBITER_STATE_MACHINE is enabled. This is the + * convention for v0 — a dedicated ctx field would be cleaner + * but would change the ABI. + */ + *state_id = (arbiter_index_t)ctx->fact_values[0].value; + return ARBITER_OK; +} + +int ARBITER_state_set(struct ARBITER_ctx *ctx, arbiter_index_t state_id) +{ + if (unlikely(ctx == NULL)) { + return ARBITER_EINVAL; + } + if (unlikely(!ctx->initialized)) { + return ARBITER_EINVAL; + } + + ctx->fact_values[0].prev_value = ctx->fact_values[0].value; + ctx->fact_values[0].value = (int32_t)state_id; + ctx->fact_values[0].changed = true; + ctx->fact_values[0].valid = true; + + return ARBITER_OK; +} diff --git a/python/arbiter/canonical.py b/python/arbiter/canonical.py index f11e0b4..e9b915d 100644 --- a/python/arbiter/canonical.py +++ b/python/arbiter/canonical.py @@ -21,6 +21,8 @@ class CanonicalModel: actions: list[dict[str, Any]] modes: list[dict[str, Any]] expressions: list[dict[str, Any]] = field(default_factory=list) + states: list[dict[str, Any]] = field(default_factory=list) + transitions: list[dict[str, Any]] = field(default_factory=list) hazards: list[dict[str, Any]] = field(default_factory=list) safety_goals: list[dict[str, Any]] = field(default_factory=list) model_hash: str = "" @@ -102,6 +104,11 @@ def canonicalize(data: dict[str, Any]) -> CanonicalModel: annotated["_expr_count"] = len(rule_exprs) rules.append(annotated) + # Flatten states and transitions (REQ-ARCH-039) + states_flat, transitions_flat, state_id_map = _flatten_states( + data.get("states", []), action_id_map, fact_id_map, conditions, + ) + model = CanonicalModel( name=data.get("model", "unnamed"), arb_version=data.get("arb_version", 0.1), @@ -111,6 +118,8 @@ def canonicalize(data: dict[str, Any]) -> CanonicalModel: actions=actions, modes=modes, expressions=expressions, + states=states_flat, + transitions=transitions_flat, hazards=data.get("hazards", []), safety_goals=data.get("safety_goals", []), fact_id_map=fact_id_map, @@ -220,6 +229,78 @@ def _flatten_conditions( conditions.append(flat) +def _flatten_states( + states_raw: list[Any], + action_id_map: dict[str, int], + fact_id_map: dict[str, int], + conditions: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, int]]: + """Flatten states and their nested transitions into linear tables. + + Returns (states, transitions, state_id_map). + Transition conditions are appended to the shared *conditions* list + so the C emitter can reuse the same conditions table. + """ + if not isinstance(states_raw, list) or not states_raw: + return [], [], {} + + # Sort states by id for determinism + states_sorted = sorted(states_raw, key=lambda s: s.get("id", "") if isinstance(s, dict) else "") + state_id_map: dict[str, int] = {} + states_out: list[dict[str, Any]] = [] + transitions_out: list[dict[str, Any]] = [] + + for idx, st in enumerate(states_sorted): + if not isinstance(st, dict) or "id" not in st: + continue + state_id_map[st["id"]] = idx + on_enter = action_id_map.get(st.get("on_enter", ""), _UINT16_MAX) + on_exit = action_id_map.get(st.get("on_exit", ""), _UINT16_MAX) + states_out.append({ + "id": st["id"], + "index": idx, + "on_enter_action": on_enter, + "on_exit_action": on_exit, + }) + + # Second pass: flatten transitions (needs state_id_map fully built) + for st in states_sorted: + if not isinstance(st, dict) or "id" not in st: + continue + source_idx = state_id_map[st["id"]] + for tr in st.get("transitions", []): + if not isinstance(tr, dict): + continue + target_id = tr.get("target", "") + target_idx = state_id_map.get(target_id, _UINT16_MAX) + + # Flatten transition conditions into the shared conditions list + cond_start = len(conditions) + when = tr.get("when", {}) + if isinstance(when, dict): + _flatten_conditions(when, conditions, fact_id_map) + cond_count = len(conditions) - cond_start + + # Flatten guard conditions + guard_start = len(conditions) + guard = tr.get("guard", {}) + if isinstance(guard, dict): + _flatten_conditions(guard, conditions, fact_id_map) + guard_count = len(conditions) - guard_start + + transitions_out.append({ + "source_state": source_idx, + "target_state": target_idx, + "condition_start": cond_start, + "condition_count": cond_count, + "guard_start": guard_start, + "guard_count": guard_count, + "priority": tr.get("priority", 0), + }) + + return states_out, transitions_out, state_id_map + + def to_canonical_json(model: CanonicalModel) -> str: """Produce deterministic JSON representation of the canonical model.""" obj = { diff --git a/python/arbiter/emit_c.py b/python/arbiter/emit_c.py index 9e83454..c5f7638 100644 --- a/python/arbiter/emit_c.py +++ b/python/arbiter/emit_c.py @@ -5,6 +5,9 @@ from .canonical import CanonicalModel +# --- Header includes for state machine support --- +_STATE_HEADER_INCLUDE = '#include ' + _OP_MAP = { "==": "ARBITER_OP_EQ", "!=": "ARBITER_OP_NE", "<": "ARBITER_OP_LT", "<=": "ARBITER_OP_LE", @@ -95,6 +98,24 @@ def emit_c_header(model: CanonicalModel, emit_trace_strings: bool = True) -> str lines.append(f"#define ARBITER_MODE_{safe_name} {idx}u") lines.append("") + + # State defines (REQ-ARCH-039) + states = getattr(model, "states", []) + if states: + lines.append(_STATE_HEADER_INCLUDE) + lines.append("") + lines.append(f"#define ARBITER_MODEL_STATE_COUNT {len(states)}u") + transitions = getattr(model, "transitions", []) + lines.append(f"#define ARBITER_MODEL_TRANSITION_COUNT {len(transitions)}u") + lines.append("") + for st in states: + safe_name = st["id"].replace(".", "_").upper() + lines.append(f"#define ARBITER_STATE_{safe_name} {st['index']}u") + lines.append("") + lines.append("extern const struct ARBITER_state_def ARBITER_generated_states[];") + lines.append("extern const struct ARBITER_transition_def ARBITER_generated_transitions[];") + lines.append("") + return "\n".join(lines) @@ -280,4 +301,37 @@ def emit_c_source(model: CanonicalModel, header_name: str = "arbiter_model.h", "", ]) + # States table (REQ-ARCH-039) + states = getattr(model, "states", []) + transitions = getattr(model, "transitions", []) + if states: + lines.append("") + lines.append("const struct ARBITER_state_def ARBITER_generated_states[] = {") + for st in states: + name = _c_str(st["id"]) if emit_trace_strings else "NULL" + lines.append( + f"\t{{ .id = {st['index']}, " + f".on_enter_action = {st['on_enter_action']}, " + f".on_exit_action = {st['on_exit_action']}, " + f".name = {name} }}," + ) + lines.append("};") + lines.append("") + + # Transitions table (REQ-ARCH-039) + if transitions: + lines.append("const struct ARBITER_transition_def ARBITER_generated_transitions[] = {") + for tr in transitions: + lines.append( + f"\t{{ .source_state = {tr['source_state']}, " + f".target_state = {tr['target_state']}, " + f".condition_start = {tr['condition_start']}, " + f".condition_count = {tr['condition_count']}, " + f".guard_start = {tr['guard_start']}, " + f".guard_count = {tr['guard_count']}, " + f".priority = {tr['priority']} }}," + ) + lines.append("};") + lines.append("") + return "\n".join(lines) diff --git a/schema/arb.schema.json b/schema/arb.schema.json index e169eca..acd5613 100644 --- a/schema/arb.schema.json +++ b/schema/arb.schema.json @@ -83,6 +83,10 @@ "type": "array", "items": { "$ref": "#/$defs/action" } }, + "states": { + "type": "array", + "items": { "$ref": "#/$defs/state" } + }, "runtime": { "type": "object" }, "tests": { "type": "array" }, "metadata": { "type": "object" } @@ -194,6 +198,53 @@ "failure_behavior": { "type": "string" }, "safe_state_action": { "type": "boolean" } } + }, + "state": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "on_enter": { "type": "string", "description": "Action id to run on entry." }, + "on_exit": { "type": "string", "description": "Action id to run on exit." }, + "transitions": { + "type": "array", + "items": { "$ref": "#/$defs/transition" } + } + } + }, + "transition": { + "type": "object", + "required": ["target"], + "properties": { + "target": { "type": "string", "description": "Target state id." }, + "priority": { "type": "integer", "minimum": 0, "default": 0 }, + "when": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { "$ref": "#/$defs/condition" } + }, + "any": { + "type": "array", + "items": { "$ref": "#/$defs/condition" } + }, + "not": { + "type": "array", + "items": { "$ref": "#/$defs/condition" } + } + } + }, + "guard": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { "$ref": "#/$defs/condition" } + } + } + } + } } } } diff --git a/subsys/arbiter/Kconfig b/subsys/arbiter/Kconfig index 9f66d17..7f1be48 100644 --- a/subsys/arbiter/Kconfig +++ b/subsys/arbiter/Kconfig @@ -284,4 +284,55 @@ config ARBITER_FPGA_OFFLOAD to offload condition evaluation and expression execution to FPGA fabric. No implementation is shipped in v1. +# ── Event-Driven Evaluation (REQ-ARCH-036) ─────────────────── + +config ARBITER_EVENT_DRIVEN + bool "Enable event-driven evaluation" + default n + help + Watch specific facts for changes and evaluate only when a + watched fact has been modified. Uses static bitmasks — + no dynamic allocation. + +# ── Multi-Model Composition (REQ-ARCH-037) ─────────────────── + +config ARBITER_COMPOSE + bool "Enable multi-model composition" + default n + help + Share facts between multiple models via a common fact bus. + Each model is attached with a mapping table; the bus + synchronizes and evaluates all models in order. + +config ARBITER_MAX_MODELS + int "Maximum number of attached models" + default 4 + depends on ARBITER_COMPOSE + help + Maximum number of model contexts that can be attached to a + single fact bus. + +# ── State Machine Support (REQ-ARCH-039) ───────────────────── + +config ARBITER_STATE_MACHINE + bool "Enable state machine support" + default n + help + Add explicit state machine evaluation with guarded + transitions, priorities, and on_enter/on_exit actions. + +config ARBITER_MAX_STATES + int "Maximum number of states" + default 16 + depends on ARBITER_STATE_MACHINE + help + Maximum number of state definitions per model. + +config ARBITER_MAX_TRANSITIONS + int "Maximum number of transitions" + default 32 + depends on ARBITER_STATE_MACHINE + help + Maximum number of transition definitions per model. + endif # ARBITER