Skip to main content

g2g_core/
staticelem.rs

1//! Static (heap-free) element model for the no-alloc / MCU path (Phase 2 of the
2//! alloc-optional core): the generic twin of the object-safe [`AsyncElement`] /
3//! [`OutputSink`], which box a future per frame (`element.rs`, the honest
4//! per-frame allocation boundary pinned by M616). Elements here are concrete types
5//! wired by direct calls and driven by a const-arity runner, so a whole pipeline
6//! monomorphizes to unboxed `async` state machines: no `dyn`, no `Box`, no
7//! allocation. This is the M620 concrete-chain pattern promoted to an API.
8//!
9//! The traits use `async fn` in trait (stable on MSRV 1.75), so a stage's future
10//! is an anonymous type inlined into the caller, never boxed. The runners are
11//! generic and executor-agnostic: on an MCU an Embassy task `.await`s them, on a
12//! host `block_on` drives them. Because nothing here allocates, a chain built from
13//! these traits links on a target with no global allocator (proven end to end by
14//! `examples/g2g-noalloc`).
15//!
16//! [`AsyncElement`]: crate::element::AsyncElement
17//! [`OutputSink`]: crate::element::OutputSink
18
19use core::future::Future;
20use core::pin::pin;
21use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
22
23use crate::error::G2gError;
24use crate::frame::Frame;
25
26/// A heap-free source: yields frames until the stream ends (`Ok(None)` at EOS).
27///
28/// The `#[allow(async_fn_in_trait)]` is intentional: this model targets a single
29/// executor (an Embassy task on an MCU, `block_on` on a host), so the auto-trait
30/// (`Send`) leakage the lint warns about is a non-issue, and avoiding it (a boxed
31/// or `-> impl Future + Send` return) would reintroduce the allocation this model
32/// exists to remove.
33#[allow(async_fn_in_trait)]
34pub trait StaticSource {
35    /// Produce the next frame, or `Ok(None)` at end of stream.
36    async fn next(&mut self) -> Result<Option<Frame>, G2gError>;
37}
38
39/// A heap-free 1:(0 or 1) transform: consumes a frame and optionally emits one (a
40/// filter/decimator returns `Ok(None)` to drop it).
41#[allow(async_fn_in_trait)]
42pub trait StaticTransform {
43    /// Transform `input`, optionally producing an output frame.
44    async fn process(&mut self, input: Frame) -> Result<Option<Frame>, G2gError>;
45}
46
47/// A heap-free terminal sink.
48#[allow(async_fn_in_trait)]
49pub trait StaticSink {
50    /// Consume one frame.
51    async fn consume(&mut self, frame: Frame) -> Result<(), G2gError>;
52}
53
54/// A heap-free 2:(0 or 1) fan-in stage (a mixer, an interleaver): consumes one
55/// frame from each of two inputs and optionally emits one. Const-arity like
56/// everything in this model: the input count is fixed in the trait, so the
57/// stage monomorphizes with no pad list. Returning `Ok(None)` drops the pair.
58#[allow(async_fn_in_trait)]
59pub trait StaticFanIn2 {
60    /// Combine one frame from each input, optionally producing an output frame.
61    async fn process2(&mut self, a: Frame, b: Frame) -> Result<Option<Frame>, G2gError>;
62}
63
64/// Compose two transforms into one, running `A` then `B` on its output, so a
65/// static chain can carry more than one middle stage without a heap-allocated
66/// element list: `Chain(a, Chain(b, c))` is a three-transform pipeline that still
67/// monomorphizes to one unboxed future. `A` dropping a frame (`Ok(None)`)
68/// short-circuits `B`.
69#[derive(Debug)]
70pub struct Chain<A, B>(pub A, pub B);
71
72impl<A: StaticTransform, B: StaticTransform> StaticTransform for Chain<A, B> {
73    async fn process(&mut self, input: Frame) -> Result<Option<Frame>, G2gError> {
74        match self.0.process(input).await? {
75            Some(mid) => self.1.process(mid).await,
76            None => Ok(None),
77        }
78    }
79}
80
81/// Fuse a transform onto a source, yielding the transform's output: the
82/// static analog of a `source ! transform` bin. A const-arity runner slot
83/// that takes one source can then carry a whole linear branch, which is how a
84/// fan-in graph gets per-input chains ([`run_sources_fanin_sink`] with a
85/// `SourceChain` in each source slot). A frame the transform drops
86/// (`Ok(None)`) is pulled past (the fused source polls the inner source
87/// again), so downstream sees only surviving frames; end of stream is the
88/// inner source's.
89#[derive(Debug)]
90pub struct SourceChain<S, T>(pub S, pub T);
91
92impl<S: StaticSource, T: StaticTransform> StaticSource for SourceChain<S, T> {
93    async fn next(&mut self) -> Result<Option<Frame>, G2gError> {
94        loop {
95            let Some(frame) = self.0.next().await? else {
96                return Ok(None);
97            };
98            if let Some(out) = self.1.process(frame).await? {
99                return Ok(Some(out));
100            }
101        }
102    }
103}
104
105/// Fuse a transform onto a sink: the static analog of a `transform ! sink`
106/// bin, giving a const-arity runner's sink slot a processing tail (a fan-in
107/// graph's `mix -> encode -> send`). A frame the transform drops never
108/// reaches the sink.
109#[derive(Debug)]
110pub struct SinkChain<T, K>(pub T, pub K);
111
112impl<T: StaticTransform, K: StaticSink> StaticSink for SinkChain<T, K> {
113    async fn consume(&mut self, frame: Frame) -> Result<(), G2gError> {
114        if let Some(out) = self.0.process(frame).await? {
115            self.1.consume(out).await?;
116        }
117        Ok(())
118    }
119}
120
121/// Drive a `source -> sink` chain to end of stream. Fully monomorphized; no `Box`,
122/// no `dyn`, no allocation.
123pub async fn run_source_sink<S, K>(mut src: S, mut sink: K) -> Result<(), G2gError>
124where
125    S: StaticSource,
126    K: StaticSink,
127{
128    while let Some(frame) = src.next().await? {
129        sink.consume(frame).await?;
130    }
131    Ok(())
132}
133
134/// Drive a `source -> transform -> sink` chain to end of stream. A transform that
135/// returns `Ok(None)` drops the frame (the sink is not called for it). Compose
136/// transforms with [`Chain`] for longer pipelines. Fully monomorphized.
137pub async fn run_source_transform_sink<S, T, K>(
138    mut src: S,
139    mut transform: T,
140    mut sink: K,
141) -> Result<(), G2gError>
142where
143    S: StaticSource,
144    T: StaticTransform,
145    K: StaticSink,
146{
147    while let Some(frame) = src.next().await? {
148        if let Some(out) = transform.process(frame).await? {
149            sink.consume(out).await?;
150        }
151    }
152    Ok(())
153}
154
155/// Drive a `{source_a, source_b} -> fan-in -> sink` graph to end of stream:
156/// the const-arity fan-in analog of [`run_source_transform_sink`]. Pull is
157/// lockstep and deterministic, one frame from each source per iteration (`a`
158/// first), and the stream ends when either source ends; a fan-in needing
159/// rate adaptation puts a resampler upstream, not a queue (there is none on
160/// this path by design). A fan-in that returns `Ok(None)` drops the pair.
161/// Fully monomorphized; no `Box`, no `dyn`, no allocation.
162pub async fn run_sources_fanin_sink<SA, SB, F, K>(
163    mut src_a: SA,
164    mut src_b: SB,
165    mut fanin: F,
166    mut sink: K,
167) -> Result<(), G2gError>
168where
169    SA: StaticSource,
170    SB: StaticSource,
171    F: StaticFanIn2,
172    K: StaticSink,
173{
174    loop {
175        let Some(a) = src_a.next().await? else {
176            return Ok(());
177        };
178        let Some(b) = src_b.next().await? else {
179            return Ok(());
180        };
181        if let Some(out) = fanin.process2(a, b).await? {
182            sink.consume(out).await?;
183        }
184    }
185}
186
187/// The outcome of one [`step_source_sink`] iteration: the frame-at-a-time analog
188/// of the `run_*` runners, for a caller that owns the loop rather than handing it
189/// to the runner. This is what lets an external scheduler drive a static pipeline
190/// one frame at a time and get control back, e.g. a C superloop calling in per
191/// frame over the FFI seam (`g2g-mcu::cffi`), or an RTOS task interleaving the
192/// pipeline with other work.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Step {
195    /// One frame was pulled from the source and delivered to the sink (or
196    /// dropped by a fused transform); call again for the next.
197    Advanced,
198    /// The source reported end of stream; no frame this step, stop calling.
199    Eos,
200    /// A stage suspended (`Poll::Pending`). The step model is for stages that
201    /// complete synchronously (polling drivers, the `g2g-mcu` mock/C seams); a
202    /// genuinely suspending pipeline belongs on a real executor (Embassy), not
203    /// a per-step caller. Reported, never silently looped.
204    Pending,
205}
206
207/// The one-frame body, as a named `async fn` driven by a single [`drive_ready`]
208/// poll (the same shape the `run_*` runners use). Kept a named fn, not an inline
209/// `async {}`, so it monomorphizes like the runners.
210async fn step_once<S, K>(src: &mut S, sink: &mut K) -> Result<bool, G2gError>
211where
212    S: StaticSource,
213    K: StaticSink,
214{
215    match src.next().await? {
216        Some(frame) => {
217            sink.consume(frame).await?;
218            Ok(true)
219        }
220        None => Ok(false),
221    }
222}
223
224/// Run exactly one frame through a `source -> sink` chain and return, instead of
225/// looping to end of stream like [`run_source_sink`]. The caller owns the loop:
226/// call it once per frame and yield in between (a C superloop over the
227/// `g2g-mcu::cffi` seam, an RTOS task). Compose a processing tail into the sink
228/// with [`SinkChain`] (and a head with [`SourceChain`] / [`Chain`]), so this one
229/// primitive steps any linear graph shape. Heap-free and single-poll like
230/// [`drive_ready`]; the source's and sink's streaming state persist across calls
231/// in the caller's `&mut` borrows.
232///
233/// Panic surface: a single-frame future does not give the optimizer the
234/// termination proof a run-to-EOS loop does, so it leaves the compiler's
235/// resumed-after-completion guard statically present in the archive. That guard
236/// is runtime-unreachable (each per-step future is polled exactly once, never
237/// again) and is NOT a data panic (no bounds / overflow / unwrap path);
238/// `tools/cffi-check.sh` asserts the step path stays heap-free and free of data
239/// panics, permitting only that one benign re-poll guard.
240pub fn step_source_sink<S, K>(src: &mut S, sink: &mut K) -> Result<Step, G2gError>
241where
242    S: StaticSource,
243    K: StaticSink,
244{
245    match drive_ready(step_once(src, sink)) {
246        Some(Ok(true)) => Ok(Step::Advanced),
247        Some(Ok(false)) => Ok(Step::Eos),
248        Some(Err(e)) => Err(e),
249        None => Ok(Step::Pending),
250    }
251}
252
253// Blanket impls so a `&mut` to a stage is itself one: the runners take ownership,
254// but a caller that keeps its sink for inspection passes a `&mut`. Heap-free (a
255// reference forward).
256impl<S: StaticSource> StaticSource for &mut S {
257    async fn next(&mut self) -> Result<Option<Frame>, G2gError> {
258        (**self).next().await
259    }
260}
261
262impl<T: StaticTransform> StaticTransform for &mut T {
263    async fn process(&mut self, input: Frame) -> Result<Option<Frame>, G2gError> {
264        (**self).process(input).await
265    }
266}
267
268impl<K: StaticSink> StaticSink for &mut K {
269    async fn consume(&mut self, frame: Frame) -> Result<(), G2gError> {
270        (**self).consume(frame).await
271    }
272}
273
274impl<F: StaticFanIn2> StaticFanIn2 for &mut F {
275    async fn process2(&mut self, a: Frame, b: Frame) -> Result<Option<Frame>, G2gError> {
276        (**self).process2(a, b).await
277    }
278}
279
280/// Drive an always-ready future with a single noop-waker poll: the minimal
281/// executor for a static chain whose stages never suspend (every `g2g-mcu`
282/// mock-peripheral element, the g2g-noalloc proof pipeline). Returns `None`
283/// if the future is `Pending`, which with a noop waker could never be woken
284/// again anyway; a suspending pipeline belongs on a real executor (Embassy).
285///
286/// Safe to call, so an application crate under `#![forbid(unsafe_code)]` can
287/// run a whole pipeline (the `unsafe` waker plumbing lives here, once).
288/// Polling exactly once (not a re-poll loop) also lets the optimizer discharge
289/// the compiler's resumed-after-completion panic arm, which the panic-free
290/// symbol proof (`tools/noalloc-check.sh`) relies on.
291///
292/// `#[inline]`: the future is taken by value, and inlining lets the caller's
293/// future be polled in place instead of being memmoved into this frame (a
294/// pipeline state machine is KBs; the footprint budgets count on the elision).
295#[inline]
296pub fn drive_ready<F: Future>(fut: F) -> Option<F::Output> {
297    const VTABLE: RawWakerVTable = RawWakerVTable::new(
298        |_| RawWaker::new(core::ptr::null(), &VTABLE),
299        |_| {},
300        |_| {},
301        |_| {},
302    );
303    // SAFETY: the vtable's clone returns an equivalent no-op waker and wake /
304    // drop are no-ops, satisfying the Waker contract.
305    let waker = unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) };
306    let mut cx = Context::from_waker(&waker);
307    let fut = pin!(fut);
308    match fut.poll(&mut cx) {
309        Poll::Ready(v) => Some(v),
310        Poll::Pending => None,
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::frame::FrameTiming;
318    use crate::memory::{MemoryDomain, SystemSlice};
319    use core::future::Future;
320    use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
321
322    // A no-op waker so the tests drive a future to completion without an executor
323    // or any allocation (the static-chain futures never yield Pending here). Keeps
324    // the test itself heap-free, matching the model under test.
325    fn noop_waker() -> Waker {
326        const VTABLE: RawWakerVTable = RawWakerVTable::new(
327            |_| RawWaker::new(core::ptr::null(), &VTABLE),
328            |_| {},
329            |_| {},
330            |_| {},
331        );
332        // SAFETY: the vtable's clone returns an equivalent no-op RawWaker and the
333        // wake/drop arms are no-ops, so the waker upholds the Waker contract.
334        unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) }
335    }
336
337    fn drive<F: Future>(fut: F) -> F::Output {
338        let waker = noop_waker();
339        let mut cx = Context::from_waker(&waker);
340        let mut fut = core::pin::pin!(fut);
341        loop {
342            if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
343                return v;
344            }
345        }
346    }
347
348    // Static buffers the sources lend zero-copy (the MCU pattern: no per-frame
349    // allocation, the bytes live in a fixed region).
350    static SAMPLES: [u8; 4] = [10, 20, 30, 40];
351    static SAMPLES_B: [u8; 3] = [15, 5, 25];
352
353    /// Emits one frame per byte of a 'static buffer, lending the byte zero-copy.
354    struct ByteSource {
355        data: &'static [u8],
356        idx: usize,
357    }
358    impl ByteSource {
359        fn over(data: &'static [u8]) -> Self {
360            Self { data, idx: 0 }
361        }
362    }
363    impl StaticSource for ByteSource {
364        async fn next(&mut self) -> Result<Option<Frame>, G2gError> {
365            if self.idx >= self.data.len() {
366                return Ok(None);
367            }
368            let i = self.idx;
369            self.idx += 1;
370            // SAFETY: the buffer is 'static and never mutated; the lent slice covers
371            // exactly one valid byte, and `free` is None (no reclamation needed).
372            let slice = unsafe {
373                SystemSlice::from_foreign(self.data.as_ptr().add(i), 1, None, core::ptr::null_mut())
374            };
375            Ok(Some(Frame::new(
376                MemoryDomain::System(slice),
377                FrameTiming {
378                    pts_ns: i as u64,
379                    ..FrameTiming::default()
380                },
381                i as u64,
382            )))
383        }
384    }
385
386    /// Drops odd-indexed frames (a decimator), proving the `Ok(None)` drop path.
387    struct KeepEven;
388    impl StaticTransform for KeepEven {
389        async fn process(&mut self, input: Frame) -> Result<Option<Frame>, G2gError> {
390            if input.sequence % 2 == 0 {
391                Ok(Some(input))
392            } else {
393                Ok(None)
394            }
395        }
396    }
397
398    /// Records the first payload byte of each frame it receives.
399    struct CollectSink {
400        seen: [u8; 8],
401        n: usize,
402    }
403    impl StaticSink for CollectSink {
404        async fn consume(&mut self, frame: Frame) -> Result<(), G2gError> {
405            if let Some(s) = frame.domain.as_system_slice() {
406                self.seen[self.n] = s[0];
407                self.n += 1;
408            }
409            Ok(())
410        }
411    }
412
413    #[test]
414    fn source_transform_sink_runs_the_static_chain() {
415        let mut sink = CollectSink { seen: [0; 8], n: 0 };
416        // Run source -> KeepEven -> sink; the runner consumes `sink` so collect the
417        // result by reading a shared cell instead. Simpler: build, run, inspect.
418        let src = ByteSource::over(&SAMPLES);
419        // Move sink in and out via a wrapper that borrows.
420        drive(run_source_transform_sink(src, KeepEven, &mut sink)).unwrap();
421        // Frames 0 and 2 survive KeepEven (seq 0,2); their bytes are SAMPLES[0], [2].
422        assert_eq!(
423            &sink.seen[..sink.n],
424            &[10, 30],
425            "even-sequence frames reached the sink"
426        );
427    }
428
429    #[test]
430    fn chain_composes_two_transforms() {
431        // KeepEven then a pass-through: same survivors, proving Chain wires A->B.
432        struct PassThrough;
433        impl StaticTransform for PassThrough {
434            async fn process(&mut self, input: Frame) -> Result<Option<Frame>, G2gError> {
435                Ok(Some(input))
436            }
437        }
438        let mut sink = CollectSink { seen: [0; 8], n: 0 };
439        drive(run_source_transform_sink(
440            ByteSource::over(&SAMPLES),
441            Chain(KeepEven, PassThrough),
442            &mut sink,
443        ))
444        .unwrap();
445        assert_eq!(
446            &sink.seen[..sink.n],
447            &[10, 30],
448            "chained transforms preserve behavior"
449        );
450    }
451
452    #[test]
453    fn source_sink_visits_every_frame() {
454        let mut sink = CollectSink { seen: [0; 8], n: 0 };
455        drive(run_source_sink(ByteSource::over(&SAMPLES), &mut sink)).unwrap();
456        assert_eq!(
457            &sink.seen[..sink.n],
458            &[10, 20, 30, 40],
459            "no transform: every frame arrives"
460        );
461    }
462
463    #[test]
464    fn source_chain_and_sink_chain_fuse_transforms() {
465        // The same KeepEven behavior, fused on the source side...
466        let mut sink = CollectSink { seen: [0; 8], n: 0 };
467        drive(run_source_sink(
468            SourceChain(ByteSource::over(&SAMPLES), KeepEven),
469            &mut sink,
470        ))
471        .unwrap();
472        assert_eq!(
473            &sink.seen[..sink.n],
474            &[10, 30],
475            "SourceChain skips dropped frames"
476        );
477        // ...and on the sink side, must agree with the plain runner.
478        let mut sink = CollectSink { seen: [0; 8], n: 0 };
479        drive(run_source_sink(
480            ByteSource::over(&SAMPLES),
481            SinkChain(KeepEven, &mut sink),
482        ))
483        .unwrap();
484        assert_eq!(
485            &sink.seen[..sink.n],
486            &[10, 30],
487            "SinkChain drops before the sink"
488        );
489    }
490
491    /// Emits whichever frame of the pair has the larger first payload byte,
492    /// dropping pairs whose `a` sequence is odd (the fan-in drop path).
493    struct PickMaxDropOdd;
494    impl StaticFanIn2 for PickMaxDropOdd {
495        async fn process2(&mut self, a: Frame, b: Frame) -> Result<Option<Frame>, G2gError> {
496            if a.sequence % 2 != 0 {
497                return Ok(None);
498            }
499            fn first_byte(f: &Frame) -> u8 {
500                f.domain.as_system_slice().map_or(0, |s| s[0])
501            }
502            Ok(Some(if first_byte(&b) > first_byte(&a) {
503                b
504            } else {
505                a
506            }))
507        }
508    }
509
510    #[test]
511    fn step_drives_one_frame_at_a_time_then_reports_eos() {
512        // The caller owns the loop: step returns Advanced per frame, then Eos.
513        let mut src = ByteSource::over(&SAMPLES);
514        let mut sink = CollectSink { seen: [0; 8], n: 0 };
515        let mut steps = 0;
516        loop {
517            match step_source_sink(&mut src, &mut sink).unwrap() {
518                Step::Advanced => steps += 1,
519                Step::Eos => break,
520                Step::Pending => panic!("synchronous stages never suspend"),
521            }
522        }
523        assert_eq!(steps, 4, "one Advanced per source frame");
524        assert_eq!(
525            &sink.seen[..sink.n],
526            &[10, 20, 30, 40],
527            "every frame delivered, in order"
528        );
529    }
530
531    #[test]
532    fn step_threads_a_fused_transform_tail_and_persists_state() {
533        // With a SinkChain tail the step primitive covers a linear graph; the
534        // transform's drop path and the sink's cross-call state both hold.
535        let mut src = ByteSource::over(&SAMPLES);
536        let mut sink = CollectSink { seen: [0; 8], n: 0 };
537        let mut tail = SinkChain(KeepEven, &mut sink);
538        for _ in 0..4 {
539            let _ = step_source_sink(&mut src, &mut tail).unwrap();
540        }
541        // Frames 0 and 2 survive KeepEven across the four steps.
542        assert_eq!(
543            &sink.seen[..sink.n],
544            &[10, 30],
545            "fused transform + persistent sink state"
546        );
547    }
548
549    #[test]
550    fn fanin_pulls_lockstep_and_ends_at_shorter_source() {
551        let mut sink = CollectSink { seen: [0; 8], n: 0 };
552        drive(run_sources_fanin_sink(
553            ByteSource::over(&SAMPLES),
554            ByteSource::over(&SAMPLES_B),
555            PickMaxDropOdd,
556            &mut sink,
557        ))
558        .unwrap();
559        // Pairs: (10,15) -> 15 (b wins); (20,5) -> dropped (odd seq);
560        // (30,25) -> 30 (a wins); then B ends, so A's 40 is never paired.
561        assert_eq!(
562            &sink.seen[..sink.n],
563            &[15, 30],
564            "lockstep pairing, drop path, EOS at min"
565        );
566    }
567}