Skip to content

no_std Support for Transform API #73

@lxsaah

Description

@lxsaah

The transform API (TransformBuilder, StatefulTransformBuilder, TransformPipeline) is partially
usable in no_std environments but the multi-input join (JoinBuilder, JoinStateBuilder,
JoinPipeline, run_join_transform) is entirely gated behind #[cfg(feature = "std")]. Even the
single-input path has implicit std dependencies through its task runner.

Embedded firmware (e.g., RP2040, STM32) running AimDB via the Embassy adapter currently cannot
define reactive derivations at all — they must hand-roll subscription loops themselves, losing the
clean declarative model that std users enjoy.

This issue tracks making the full transform API available under no_std + alloc.


Current State

What is std-only and why

Symbol File std Dependency
JoinBuilder<O, R> transform.rs:257 tokio::sync::mpsc::UnboundedSender
JoinInputFactory<R> (type alias) transform.rs:264 tokio::sync::mpsc::UnboundedSender
JoinStateBuilder<O, S, R> transform.rs:357 Same — propagated from JoinBuilder
JoinPipeline<O, R> transform.rs:401 Same — propagated from JoinBuilder
run_join_transform(...) transform.rs:517 tokio::sync::mpsc::unbounded_channel
TransformDescriptor storage in TypedRecord typed_record.rs:27-52 std::sync::Arc, std::boxed::Box

The single-input path (TransformBuilderTransformPipelinerun_single_transform) itself
contains no direct std imports, but it lives inside a crate that conditionally uses
std::boxed::Box vs alloc::boxed::Box, so it compiles fine under no_std + alloc. The task
runner (run_single_transform) is already clean.

The only real blocker is the join path, which uses tokio::sync::mpsc as a fan-in channel.

What already works in no_std

  • TransformBuilder / StatefulTransformBuilder / TransformPipeline — struct layout is clean
  • run_single_transform — pure async loop, no std imports
  • TransformDescriptor — works with alloc (uses alloc::boxed::Box, alloc::vec::Vec)

Proposed Solution

1 — Single-input transform: enable unconditionally (easy, low risk)

Remove the #[cfg(feature = "std")] guards on the TransformDescriptor fields in
typed_record.rs that prevent the transform descriptor from being stored on no_std targets. The
descriptor itself only needs alloc. The std guard was inherited transitively from JoinBuilder
and is not required here.

Change: In typed_record.rs, gate the TransformDescriptor field on alloc instead of std:

// Before
#[cfg(feature = "std")]
pub(crate) transform: Option<TransformDescriptor<T, R>>,

// After
#[cfg(feature = "alloc")]
pub(crate) transform: Option<TransformDescriptor<T, R>>,

2 — Join transform: replace tokio::sync::mpsc with embassy-sync

The join's fan-in channel is currently tokio::sync::mpsc::UnboundedSender. On Embassy targets
this can be replaced with embassy_sync::channel::Channel<M, JoinTrigger, CAP>.

The key design tension is capacity: tokio's unbounded channel has no compile-time size;
Embassy's Channel is const-generic and statically allocated. We have two options:

Option A — Const-generic capacity parameter on JoinBuilder (recommended)

Add a const CAP: usize generic to JoinBuilder (and propagate through JoinStateBuilder,
JoinPipeline, run_join_transform). Provide a type alias with a sensible default:

// no_std path
pub struct JoinBuilder<O, R, const CAP: usize = 8> { ... }

// The channel backing the fan-in queue
static JOIN_CHANNEL: Channel<CriticalSectionRawMutex, JoinTrigger, CAP> = Channel::new();

Note: Embassy channels require a 'static reference. The standard pattern is to declare the
channel as a module-level static in the user's firmware. JoinBuilder will need to accept a
&'static Channel<M, JoinTrigger, CAP> from the caller, rather than creating it internally.

Option B — heapless::spsc::Queue as an alloc-free ring

Simpler from a library perspective, but caller must decide queue capacity up-front and SPSC does
not support the multi-forwarder pattern without additional synchronization.

Recommended approach: split the join implementation by feature flag

aimdb-core/src/transform/
    mod.rs            -- re-exports, shared types (JoinTrigger, TransformDescriptor)
    single.rs         -- TransformBuilder, StatefulTransformBuilder, TransformPipeline
    join_std.rs       -- JoinBuilder (std path, tokio::mpsc, unchanged)
    join_nostd.rs     -- JoinBuilder (no_std path, embassy-sync Channel)

Use a feature gate to select the right module:

#[cfg(feature = "std")]
pub use join_std::{JoinBuilder, JoinPipeline, JoinStateBuilder};

#[cfg(all(not(feature = "std"), feature = "alloc"))]
pub use join_nostd::{JoinBuilder, JoinPipeline, JoinStateBuilder};

3 — Embassy adapter: expose transform_join via EmbassyRecordRegistrarExt

The macro-generated extension trait EmbassyRecordRegistrarExt currently only wires up
source, tap, and with_transform. Add with_transform_join to the Embassy arm once step 2
is complete.


API Impact

Single-input (step 1 only — backwards compatible)

No user-visible API changes. Existing std code is unchanged. Embassy firmware gains the ability
to call .transform() on a record without code changes.

Join (step 2 — new API surface on embedded)

// Firmware (no_std) — user provides a static channel for the fan-in queue
static JOIN_CH: Channel<CriticalSectionRawMutex, JoinTrigger, 16> = Channel::new();

registrar
    .register::<HeatIndex>("sensor::HeatIndex", buffer_sized::<4, 2>(BufferType::SpmcRing))
    .transform_join(&JOIN_CH)
    .input::<Temperature>("sensor::Temperature")
    .input::<Humidity>("sensor::Humidity")
    .with_state(HeatIndexState::default())
    .on_trigger(|trigger, state, producer| async move {
        match trigger.index() {
            0 => state.temperature = trigger.as_input::<Temperature>().copied(),
            1 => state.humidity    = trigger.as_input::<Humidity>().copied(),
            _ => {}
        }
        if let (Some(t), Some(h)) = (state.temperature, state.humidity) {
            let _ = producer.produce(heat_index(t, h)).await;
        }
    });

The std API stays identical (caller does not pass a channel; one is created internally).


Implementation Checklist

  • Step 1 — Remove spurious std gate from TransformDescriptor in typed_record.rs; gate on alloc instead
  • Step 1 — Remove spurious std gate from TypedRecord::set_transform() in typed_record.rs
  • Step 1 — Verify single-input transform compiles under --target thumbv7em-none-eabihf --features embassy-runtime
  • Step 1 — Add integration test in aimdb-embassy-adapter/tests/ for single-input map transform
  • Step 2 — Split transform.rs into transform/mod.rs, transform/single.rs, transform/join_std.rs
  • Step 2 — Implement transform/join_nostd.rs using embassy_sync::channel::Channel
  • Step 2 — Define JoinTrigger as a shared no_std-safe type (already clean — no std imports)
  • Step 2 — Wire transform_join into EmbassyRecordRegistrarExt macro
  • Step 2 — Update Embassy buffer Cargo.toml if a new embassy-sync import is needed inside aimdb-core
  • Step 2 — Add Embassy join integration test
  • Docs — Update aimdb-usage-guide.md to document the no_std join API difference (static channel requirement)
  • Docs — Add no_std section to transform.rs module-level doc comment

Risk & Constraints

Risk Mitigation
'static lifetime requirement on Embassy channels Caller provides the channel as &'static; document pattern clearly
Const-generic capacity leaks into public API Provide a DefaultJoinBuilder<O, R> type alias with CAP = 16
Feature flag complexity growing in aimdb-core Keep std/alloc/no_std split consistent with existing typed_record.rs patterns
Breaking change if JoinBuilder gains const CAP Gate new generic behind no_std; std path remains unchanged

Acceptance Criteria

  1. cargo check --target thumbv7em-none-eabihf --features embassy-runtime passes with a crate that uses .transform() (single-input map).
  2. cargo check --target thumbv7em-none-eabihf --features embassy-runtime passes with a crate that uses .transform_join() (multi-input join).
  3. All existing std tests continue to pass (make test).
  4. A new Embassy adapter integration test demonstrates both single and multi-input transforms on a mock embedded database.
  5. No new std-only imports are introduced in aimdb-core/src/transform/single.rs.

Related

  • aimdb-embassy-adapter/src/buffer.rsPubSubChannel, Watch, Channel already in use; same primitives can back the join fan-in
  • aimdb-core/src/typed_record.rs — existing #[cfg(feature = "std")] / #[cfg(not(feature = "std"))] pattern to follow
  • aimdb-core/src/transform.rs — file to be refactored
  • tools/aimdb-mcp/ — MCP server graph introspection should auto-discover Embassy transforms once they are registered in the dependency graph (no extra work expected)

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions