Skip to main content

ph_eventing/
block.rs

1//! Complete, contiguous sample blocks and a fill-side builder.
2//!
3//! `Block` is deliberately a payload, not a queue. Compose it with the
4//! transport whose overload policy matches the application:
5//!
6//! - `EventBuf<Block<T, N>, Q>` queues up to `Q` complete blocks and rejects
7//!   the newest block when full;
8//! - `LatestBuf<Block<T, N>>` retains only the latest complete
9//!   block (decision D3 composition).
10//!
11//! Publication cannot expose a partial block because [`BlockBuilder`] only
12//! yields a [`Block`] after all `N` samples have been written. Dropping or
13//! clearing a partially filled builder publishes nothing.
14//!
15//! Timestamps are payload policy: use a timestamped sample type for `T` when
16//! each sample needs a stamp. The transport does not impose one.
17//!
18//! # Known limitation: sequence-span aliasing
19//!
20//! The contiguity check compares `u32` sequence values and nothing else, and
21//! the successor skips reserved `0`, so sequence identity is modular over the
22//! `2^32 - 1` nonzero span — the same counter-width boundary the transports
23//! disclose. A partial builder held while upstream omits *exactly one whole
24//! span* of sequences (or any whole multiple) sees the recurring value as the
25//! expected successor and completes the block as "contiguous" despite
26//! ~4.29 billion omitted samples; the gap-rejection promise (F2) is exact
27//! only below one span. Reachability: the omission must span `2^32 - 1`
28//! sequences while the same partial builder stays live — ~71.6 minutes of
29//! outage at a sustained 1 MHz sample rate, ~5 days at 10 kHz. The chosen
30//! policy is to keep the sequence one word and disclose the bound rather than
31//! carry a wider epoch: recovery from outages is the application's job —
32//! `clear()` the builder when your staleness watchdog or link-layer detects a
33//! gap it cannot bound below one span.
34//!
35//! # Costs and integration (measured; bound by decisions D3 and P)
36//!
37//! **RAM is multiple complete blocks, always.** The latest composition
38//! (`LatestBuf<Block<T, N>>`) holds three block slots plus this private
39//! builder — 136–8,280 bytes of combined channel + builder RAM across the
40//! measured 2/8/16-byte × `N = 8/32/128` grid. The queued composition
41//! stores `Q` blocks plus the builder. The cost is fixed in size but lives
42//! wherever you place the value: a `const`-constructed `static` lands in
43//! `.bss` (no flash image, no startup copy); a local consumes **stack**,
44//! and at up to 8,280 bytes per combined shape that is a real stack
45//! budget, not a rounding error. It is not small either way: state the
46//! number for your shape and charge it to the right budget.
47//!
48//! **Small windows can invert the economics.** Continuous block release
49//! beats `N` individual sample publications for every measured 2-byte row
50//! and for 8/16-byte samples at `N >= 32`, but costs 54% / 31% *more* at
51//! the 8/16-byte `N = 8` corners. For tiny windows, per-sample
52//! publication through a plain channel may be the cheaper shape.
53//!
54//! **Publication cost scales with block bytes** — 150–8,651 reference
55//! instructions across the measured grid — and rejection is within 2–25
56//! instructions of acceptance, because the complete rejected block is
57//! preserved and returned rather than reduced to a scalar error. Budget
58//! rejection like acceptance, not like error plumbing.
59//!
60//! **DMA integrations: the double copy is currently unavoidable in ISR
61//! context.** A DMA engine has already written the samples once, and this
62//! builder's storage is deliberately private — the public API offers no
63//! address or writable slice a DMA controller could target — so filling
64//! the builder from the DMA buffer crosses the payload a second time.
65//! Either budget both copies against the accepted row for your shape, or
66//! publish from task context where the copy is off the interrupt path. A
67//! direct-to-granted-slot fill API is exactly the registered reopening
68//! condition of cycle decision S (the deferred SlotPool foundation) — a
69//! real adopter with this requirement reopens that lane rather than
70//! prying the builder open. (DMA cache maintenance remains outside this
71//! crate, per the taxonomy's out-of-scope list.)
72//!
73//! **No partial block is ever visible** — sample-level freshness inside a
74//! filling window is unobtainable by design, stated here so it is chosen,
75//! not discovered.
76//!
77//! The measured rows behind these numbers live in
78//! `docs/proposals/block-buf-measurements.md` and the joint composition
79//! matrix; the decision record is `docs/records/block-buf.md`.
80//!
81//! # Example
82//! ```
83//! use ph_eventing::{BlockBuilder, EventBuf};
84//!
85//! let mut fill = BlockBuilder::<i16, 4>::new();
86//! for (sequence, sample) in [(10, 1), (11, 2), (12, 3)] {
87//!     assert!(fill.push(sequence, sample).expect("contiguous").is_none());
88//! }
89//! let block = fill.push(13, 4).expect("contiguous").expect("complete");
90//!
91//! let queue = EventBuf::<_, 2>::new();
92//! let producer = queue.try_producer().expect("producer");
93//! let consumer = queue.try_consumer().expect("consumer");
94//! // Backpressure is returned, never unwrapped: a full queue hands the
95//! // complete block back through `Err` for the caller's policy.
96//! assert!(producer.push(block).is_ok());
97//! assert_eq!(consumer.pop().expect("one block queued").samples(), &[1, 2, 3, 4]);
98//! ```
99//!
100//! A zero-sized block is rejected at compile time:
101//!
102//! ```compile_fail,E0080
103//! use ph_eventing::BlockBuilder;
104//! const BAD: BlockBuilder<u8, 0> = BlockBuilder::new();
105//! # let _ = BAD;
106//! ```
107
108use core::mem::MaybeUninit;
109
110/// A complete, contiguous block of `N` samples.
111///
112/// Sequence `0` is reserved. The first and last sequence values describe the
113/// inclusive range represented by `samples`; wrap skips the reserved value.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115#[must_use = "a completed block is the publishable window; dropping it discards N samples"]
116pub struct Block<T: Copy, const N: usize> {
117    first_sequence: u32,
118    last_sequence: u32,
119    samples: [T; N],
120}
121
122impl<T: Copy, const N: usize> Block<T, N> {
123    /// Sequence of the first sample in the block.
124    #[must_use]
125    pub const fn first_sequence(&self) -> u32 {
126        self.first_sequence
127    }
128
129    /// Sequence of the last sample in the block.
130    #[must_use]
131    pub const fn last_sequence(&self) -> u32 {
132        self.last_sequence
133    }
134
135    /// The complete contiguous sample array.
136    #[must_use]
137    pub const fn samples(&self) -> &[T; N] {
138        &self.samples
139    }
140
141    /// Consume the block and return its sample array.
142    #[must_use]
143    pub fn into_samples(self) -> [T; N] {
144        self.samples
145    }
146}
147
148/// Why a sample could not be appended to a [`BlockBuilder`].
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150#[must_use = "the rejected sample rides in this error; dropping it unseen loses the sample"]
151pub enum FillError<T: Copy> {
152    /// Sequence `0` is reserved and never identifies a sample.
153    ReservedSequence {
154        /// The rejected sample.
155        sample: T,
156    },
157    /// The sequence was not the next contiguous value.
158    Discontinuous {
159        /// The required next sequence.
160        expected: u32,
161        /// The sequence supplied by the caller.
162        received: u32,
163        /// The rejected sample.
164        sample: T,
165    },
166}
167
168/// Privately fills one block and publishes it to the caller only when complete.
169///
170/// A discontinuous sample is rejected without changing the partial block. The
171/// caller can preserve it, or call [`clear`](Self::clear) and retry the returned
172/// sample as the start of a new window. This keeps loss policy explicit.
173pub struct BlockBuilder<T: Copy, const N: usize> {
174    samples: [MaybeUninit<T>; N],
175    len: usize,
176    first_sequence: u32,
177    last_sequence: u32,
178}
179
180impl<T: Copy, const N: usize> BlockBuilder<T, N> {
181    /// Create an empty builder.
182    ///
183    /// `N == 0` is rejected at compile time.
184    #[must_use]
185    pub const fn new() -> Self {
186        const { assert!(N > 0, "BlockBuilder capacity must be greater than zero") };
187        Self {
188            samples: [const { MaybeUninit::uninit() }; N],
189            len: 0,
190            first_sequence: 0,
191            last_sequence: 0,
192        }
193    }
194
195    /// Append one sequenced sample.
196    ///
197    /// Returns `Ok(None)` while the block is partial and `Ok(Some(block))`
198    /// exactly when the `N`th sample completes it. Completion also resets the
199    /// builder, ready for the next block.
200    pub fn push(&mut self, sequence: u32, sample: T) -> Result<Option<Block<T, N>>, FillError<T>> {
201        if sequence == 0 {
202            return Err(FillError::ReservedSequence { sample });
203        }
204
205        if self.len != 0 {
206            let expected = next_sequence(self.last_sequence);
207            if sequence != expected {
208                return Err(FillError::Discontinuous {
209                    expected,
210                    received: sequence,
211                    sample,
212                });
213            }
214        } else {
215            self.first_sequence = sequence;
216        }
217
218        self.samples[self.len].write(sample);
219        self.len += 1;
220        self.last_sequence = sequence;
221
222        if self.len != N {
223            return Ok(None);
224        }
225
226        // SAFETY: `len == N`, and `len` advances only after the corresponding
227        // slot is written. `T: Copy`, so copying the initialized array out does
228        // not invalidate the backing `MaybeUninit` storage.
229        let samples = unsafe { self.samples.as_ptr().cast::<[T; N]>().read() };
230        let block = Block {
231            first_sequence: self.first_sequence,
232            last_sequence: self.last_sequence,
233            samples,
234        };
235        self.clear();
236        Ok(Some(block))
237    }
238
239    /// Discard the partial block, if any.
240    pub fn clear(&mut self) {
241        self.len = 0;
242        self.first_sequence = 0;
243        self.last_sequence = 0;
244    }
245
246    /// Number of samples currently held in the private partial block.
247    #[must_use]
248    pub const fn len(&self) -> usize {
249        self.len
250    }
251
252    /// Whether no partial block is being filled.
253    #[must_use]
254    pub const fn is_empty(&self) -> bool {
255        self.len == 0
256    }
257
258    /// Number of samples required for a complete block.
259    #[must_use]
260    pub const fn capacity(&self) -> usize {
261        N
262    }
263
264    /// Sequence required by the next `push`, or `None` when any non-zero
265    /// sequence may start a new block.
266    #[must_use]
267    pub const fn expected_sequence(&self) -> Option<u32> {
268        if self.len == 0 {
269            None
270        } else {
271            Some(next_sequence(self.last_sequence))
272        }
273    }
274}
275
276impl<T: Copy, const N: usize> Default for BlockBuilder<T, N> {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282impl<T: Copy, const N: usize> core::fmt::Debug for BlockBuilder<T, N> {
283    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
284        f.debug_struct("BlockBuilder")
285            .field("len", &self.len)
286            .field("capacity", &N)
287            .field("first_sequence", &self.first_sequence)
288            .field("last_sequence", &self.last_sequence)
289            .finish_non_exhaustive()
290    }
291}
292
293const fn next_sequence(sequence: u32) -> u32 {
294    if sequence == u32::MAX {
295        1
296    } else {
297        sequence + 1
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::EventBuf;
305
306    #[test]
307    fn completes_only_after_n_contiguous_samples() {
308        let mut fill = BlockBuilder::<u16, 3>::new();
309        assert_eq!(fill.push(41, 4), Ok(None));
310        assert_eq!(fill.push(42, 5), Ok(None));
311        let block = fill.push(43, 6).unwrap().unwrap();
312        assert_eq!(block.first_sequence(), 41);
313        assert_eq!(block.last_sequence(), 43);
314        assert_eq!(block.samples(), &[4, 5, 6]);
315        assert!(fill.is_empty());
316    }
317
318    #[test]
319    fn discontinuity_check_is_modular_over_the_span() {
320        // The chosen policy's pin (F2 span non-promise): contiguity compares
321        // `u32` sequence identity and nothing else. The recurring value after
322        // exactly one whole `2^32 - 1` span is therefore indistinguishable
323        // from the true successor and is accepted — including across the
324        // reserved-zero wrap. If this test's expectation ever changes, the
325        // policy changed (an epoch was added) and the module-doc disclosure,
326        // record row, and proposal F2 text must change with it.
327        let mut fill = BlockBuilder::<u16, 2>::new();
328        assert_eq!(fill.push(u32::MAX, 1), Ok(None));
329        // Successor skips reserved 0: expected is 1, and any occurrence of
330        // sequence 1 — the immediate successor or the wrap-aliased ordinal a
331        // whole span later — completes the block as contiguous.
332        assert!(matches!(
333            fill.push(2, 9),
334            Err(FillError::Discontinuous {
335                expected: 1,
336                received: 2,
337                ..
338            })
339        ));
340        let block = fill.push(1, 2).unwrap().unwrap();
341        assert_eq!(block.samples(), &[1, 2]);
342    }
343
344    #[test]
345    fn completion_resets_for_the_next_block() {
346        let mut fill = BlockBuilder::<u8, 1>::new();
347        assert_eq!(fill.push(7, 9).unwrap().unwrap().into_samples(), [9]);
348        assert_eq!(fill.push(8, 10).unwrap().unwrap().into_samples(), [10]);
349    }
350
351    #[test]
352    fn rejects_reserved_zero_without_changing_partial_block() {
353        let mut fill = BlockBuilder::<u8, 2>::new();
354        assert_eq!(fill.push(9, 1), Ok(None));
355        assert_eq!(
356            fill.push(0, 2),
357            Err(FillError::ReservedSequence { sample: 2 })
358        );
359        assert_eq!(fill.len(), 1);
360        assert_eq!(fill.expected_sequence(), Some(10));
361    }
362
363    #[test]
364    fn rejects_gap_without_hiding_loss_policy() {
365        let mut fill = BlockBuilder::<u8, 2>::new();
366        assert_eq!(fill.push(9, 1), Ok(None));
367        assert_eq!(
368            fill.push(11, 2),
369            Err(FillError::Discontinuous {
370                expected: 10,
371                received: 11,
372                sample: 2
373            })
374        );
375        assert_eq!(fill.len(), 1);
376    }
377
378    #[test]
379    fn clear_discards_a_partial_block() {
380        let mut fill = BlockBuilder::<u8, 4>::new();
381        assert_eq!(fill.push(1, 1), Ok(None));
382        fill.clear();
383        assert!(fill.is_empty());
384        assert_eq!(fill.expected_sequence(), None);
385        assert_eq!(fill.push(20, 2), Ok(None));
386    }
387
388    #[test]
389    fn sequence_wrap_skips_zero() {
390        let mut fill = BlockBuilder::<u8, 2>::new();
391        assert_eq!(fill.push(u32::MAX, 1), Ok(None));
392        let block = fill.push(1, 2).unwrap().unwrap();
393        assert_eq!(block.first_sequence(), u32::MAX);
394        assert_eq!(block.last_sequence(), 1);
395    }
396
397    #[test]
398    fn works_without_default_bound() {
399        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
400        struct NoDefault(u8);
401
402        let mut fill = BlockBuilder::<NoDefault, 1>::new();
403        let block = fill.push(1, NoDefault(3)).unwrap().unwrap();
404        assert_eq!(block.samples(), &[NoDefault(3)]);
405    }
406
407    #[test]
408    fn default_and_capacity_match_new() {
409        let fill = BlockBuilder::<u8, 8>::default();
410        assert_eq!(fill.capacity(), 8);
411        assert_eq!(fill.len(), 0);
412    }
413
414    #[test]
415    fn event_buf_composition_queues_and_returns_a_rejected_block() {
416        let mut fill = BlockBuilder::<u8, 2>::new();
417        assert_eq!(fill.push(1, 10), Ok(None));
418        let first = fill.push(2, 11).unwrap().unwrap();
419        assert_eq!(fill.push(3, 12), Ok(None));
420        let second = fill.push(4, 13).unwrap().unwrap();
421
422        let queue = EventBuf::<Block<u8, 2>, 1>::new();
423        let producer = queue.try_producer().unwrap();
424        let consumer = queue.try_consumer().unwrap();
425        assert_eq!(producer.push(first), Ok(()));
426        assert_eq!(producer.push(second), Err(second));
427        assert_eq!(consumer.pop(), Some(first));
428    }
429}