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}