Skip to main content

subetha_cxc/
capacity_adaptive_ring.rs

1//! `CapacityAdaptiveRing`: runtime-resizable wrapper around
2//! [`AdaptiveRing`] that adds capacity-axis
3//! morphing to the polymorphic substrate.
4//!
5//! Where [`AdaptiveRing`] morphs between shapes
6//! (SPSC / MPSC / MPMC / Vyukov) at a fixed capacity and
7//! [`LocaleAdaptiveRing`](crate::LocaleAdaptiveRing) morphs between
8//! locales (Anon / File / ShmFs) at a fixed capacity,
9//! `CapacityAdaptiveRing` morphs the capacity itself: callers (or a
10//! sidecar policy) call [`morph_capacity_to`](CapacityAdaptiveRing::morph_capacity_to)
11//! with a new power-of-two slot count, the substrate allocates a
12//! fresh underlying ring at the new size, drains in-flight items
13//! from the old backing into the new one, bumps a pin generation so
14//! outstanding pinned handles invalidate, and atomically swaps the
15//! active backing.
16//!
17//! # Why a fourth axis
18//!
19//! Shape morph addresses "the number of producers / consumers
20//! changed at runtime". Locale morph addresses "we need to migrate
21//! the bytes between Anon / File / ShmFs storage tiers". Capacity
22//! morph addresses "the workload's queueing depth requirement
23//! exceeds (or falls below) the ring's slot count, and we want to
24//! grow (or shrink) without re-creating the whole ring from
25//! scratch". A sidecar that observes producer-side backpressure
26//! events or consumer-side starvation drives the morph; user code
27//! also calls `morph_capacity_to` directly when the application
28//! has out-of-band knowledge of expected load.
29//!
30//! # Constraints
31//!
32//! - **Power-of-two capacity preserved.** New capacity must be a
33//!   power of two and at least 2. The slot-index calculation stays
34//!   `hash & (capacity - 1)` = one AND instruction. Non-pow2 sizes
35//!   return [`CapacityMorphError::InvalidCapacity`].
36//! - **Grow and shrink both succeed unconditionally.** In-flight
37//!   items physically stay in the old (larger or smaller)
38//!   AdaptiveRing as part of the stale list; the new capacity
39//!   governs only items the producer pushes after the morph. The
40//!   consumer's `try_recv` walks the stale list oldest-first then
41//!   falls through to active, so every in-flight item still
42//!   drains in send-order across the morph boundary. The
43//!   `CannotShrinkInFlight` enum variant is preserved for API
44//!   stability but is never returned by this implementation.
45//! - **Pin invalidation is caller-polled.** Outstanding
46//!   [`PinnedCapacity`] handles observe the generation bump on the
47//!   next `is_still_valid()` call. Hot loops sample at whatever
48//!   cadence fits their latency budget; the substrate does not
49//!   push.
50//! - **Morph is serialised.** A single in-flight morph at a time;
51//!   concurrent callers of `morph_capacity_to` are mutex-serialised
52//!   so the stale-list push and atomic active swap are atomic with
53//!   respect to other morphs. Producer / consumer hot-path ops are
54//!   NOT serialised against the morph - they keep dispatching via
55//!   the ArcSwap pointer.
56//! - **Consumer is sole reader of every backing.** Producers only
57//!   write to active; morphs never read from any backing. This is
58//!   what keeps the per-backing SPSC/MPSC/MPMC contract intact
59//!   across morphs - exactly one reader touches each
60//!   `SpscRingCore`, even when the active backing changes.
61//!
62//! # Cross-process and cross-host
63//!
64//! For in-process and cross-thread use, the
65//! [`create_anon`](CapacityAdaptiveRing::create_anon) constructor
66//! holds the active ring in an [`ArcSwap`]; the morph is one
67//! atomic store on the active pointer plus a push onto the stale
68//! list. The consumer's `try_recv` walks the stale list before
69//! reading from active, picking up every in-flight item in
70//! send-order without ever racing the morph thread.
71//!
72//! For file-backed cross-process use,
73//! [`create`](CapacityAdaptiveRing::create) names the initial
74//! backing `{base}.cap_{N}.bin` and every morph's backing
75//! `{base}.cap_{N}_g{seq}.bin` (the [`Shm`](BackingTarget::Shm)
76//! locale uses `{prefix}_cap_{N}_g{seq}`); each backing is a full
77//! [`AdaptiveRing`], so a second process attaches to any one of them
78//! by that name through [`AdaptiveRing::open`]. The wrapper itself
79//! is per-process: a morph swaps THIS process's active pointer and
80//! never reaches into a peer, and the morph sequence is process-local
81//! (two processes each calling `morph_capacity_to` would mint
82//! different `seq` numbers, hence different files). The cross-process
83//! pattern is therefore one owner per backing: the morphing process
84//! creates each backing, the application publishes which backing is
85//! active (a shared control value the reader polls), and the reader
86//! process opens each successive backing as it becomes active,
87//! draining the prior one to empty before switching. The
88//! `capacity_morph_xproc` example drives exactly this - two
89//! processes, a shared control atomic, every item delivered once and
90//! in order across each resize.
91//!
92//! Over a QUIC / TCP bridge the ring's bytes are ferried as fixed
93//! 64-byte slots regardless of either side's ring size, so ring
94//! capacity is per-host independent: a capacity morph on one host
95//! needs no coordination with the peer for correctness. The bridges
96//! carry the ring's data on their stream; they carry no
97//! capacity-morph control signal.
98
99use std::path::{Path, PathBuf};
100use std::sync::Arc;
101use std::sync::atomic::{AtomicU64, Ordering};
102
103use arc_swap::ArcSwap;
104use parking_lot::Mutex;
105
106use crate::adaptive_ring::{AdaptiveError, AdaptiveRing, RingShape};
107use crate::ordering::{default_stamp_kind, OrderingMode, StampKind};
108use crate::shared_ring::RingError;
109
110/// Locale target for a capacity wrapper's backings. Public mirror
111/// of the construction-time locale choice, used by
112/// [`RingConfig`] to retarget the locale as part of a compound
113/// morph: subsequent backings (and prewarms) allocate at the new
114/// locale.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum BackingTarget {
117    /// In-process anonymous mmap backings.
118    Anon,
119    /// File-backed; per-morph file at `{base}.cap_{N}_g{seq}.bin`.
120    File(PathBuf),
121    /// Named-shm backings at `{prefix}_cap_{N}_g{seq}`.
122    Shm(String),
123}
124
125/// Compound morph target for [`CapacityAdaptiveRing::morph_to_config`].
126/// Every axis is optional; `None` keeps the current value. One
127/// compound morph builds ONE fresh backing at the combined target,
128/// mirrors registrations once, bumps the pin generation once, and
129/// appends the displaced active to the stale list once - however
130/// many axes changed.
131#[derive(Debug, Clone, Default)]
132pub struct RingConfig {
133    /// Target shape (`None` = keep the active backing's shape).
134    pub shape: Option<RingShape>,
135    /// Target capacity, pow2 >= 2 (`None` = keep).
136    pub capacity: Option<usize>,
137    /// Target locale (`None` = keep). Setting this retargets the
138    /// wrapper's locale for this morph AND every subsequent morph
139    /// / prewarm.
140    pub locale: Option<BackingTarget>,
141}
142
143/// Errors returned by capacity-morph operations.
144#[derive(Debug)]
145pub enum CapacityMorphError {
146    /// Target capacity is not a power of two, or is less than 2.
147    InvalidCapacity,
148    /// Never returned. Shrinks always succeed: in-flight items stay
149    /// physically in the old AdaptiveRing as part of the stale list
150    /// and the consumer drains them through `try_recv`'s stale-walk,
151    /// so there is nothing for a shrink to refuse. The variant is
152    /// part of the public enum and kept so matches stay exhaustive.
153    CannotShrinkInFlight { in_flight: usize, new_capacity: usize },
154    /// Underlying ring allocation, push, or pop failed during the
155    /// morph. The active backing is unchanged.
156    Ring(RingError),
157    /// Producer / consumer registration on the new backing
158    /// failed (e.g. mirroring more peers than the configured
159    /// max_producers / max_consumers).
160    Adaptive(AdaptiveError),
161}
162
163impl From<RingError> for CapacityMorphError {
164    fn from(e: RingError) -> Self { Self::Ring(e) }
165}
166
167impl From<AdaptiveError> for CapacityMorphError {
168    fn from(e: AdaptiveError) -> Self { Self::Adaptive(e) }
169}
170
171impl std::fmt::Display for CapacityMorphError {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            Self::InvalidCapacity => write!(f, "capacity must be pow2 >= 2"),
175            Self::CannotShrinkInFlight { in_flight, new_capacity } => write!(
176                f,
177                "shrink rejected: {in_flight} in-flight items exceed new capacity {new_capacity}",
178            ),
179            Self::Ring(e) => write!(f, "ring error during morph: {e:?}"),
180            Self::Adaptive(e) => write!(f, "adaptive ring error during morph: {e:?}"),
181        }
182    }
183}
184
185impl std::error::Error for CapacityMorphError {}
186
187/// Runtime-resizable adaptive ring.
188///
189/// See the module-level docs for the morph protocol. Hot-path
190/// `try_send` / `try_recv` calls hop through one ArcSwap load plus
191/// the active [`AdaptiveRing`]'s dispatch.
192pub struct CapacityAdaptiveRing {
193    /// Combined active + stale list behind a single ArcSwap. Hot-
194    /// path `try_send` / `try_recv` performs one ArcSwap load
195    /// (~5-10 ns) and delegates to the active backing's native
196    /// dispatch; no mutex acquisition in steady state. Morph
197    /// builds a fresh `RingState { active: new, stale: prune(old.stale) ++ old.active }`
198    /// and atomic-swaps it; the FIFO-correctness combined-snapshot
199    /// is given for free by the single atomic load.
200    state: ArcSwap<RingState>,
201    /// Bumped on every successful morph so outstanding
202    /// [`PinnedCapacity`] handles invalidate.
203    pin_generation: AtomicU64,
204    /// Cached observable capacity of the active backing; stays in
205    /// lockstep with `active`.
206    capacity_atom: AtomicU64,
207    /// max_producers configured at construction; mirrored on every
208    /// newly-allocated backing during a morph.
209    max_producers: usize,
210    /// max_consumers configured at construction; mirrored on every
211    /// newly-allocated backing during a morph.
212    max_consumers: usize,
213    /// Locale source for the morph-allocated backings. `Anon` is
214    /// in-process; `File(base)` allocates new backings at
215    /// `{base}.cap_{N}_g{morph_seq}.bin` per morph; `Shm(prefix)`
216    /// allocates named-shm backings at
217    /// `{prefix}_cap_{N}_g{morph_seq}` per morph (cross-process
218    /// visible, RAM-resident). Behind a mutex because a compound
219    /// morph with a locale axis retargets it at runtime; read by
220    /// `build_backing` (also reachable off the morph lock via
221    /// `prewarm`).
222    backing_source: Mutex<BackingTarget>,
223    /// Monotonic morph counter. Bumped on every morph BEFORE the
224    /// new backing is allocated so the new path / shm-name is
225    /// unique even when callers cycle through the same capacities
226    /// (e.g. 256 -> 1024 -> 256 -> 1024 -> ...). File-backed and
227    /// shmfs locales both need this because the prior backing's
228    /// file / shm region is still mapped from the stale list, and
229    /// attempting to create another at the same name fails on
230    /// Windows in particular.
231    morph_seq: AtomicU64,
232    /// Stamp kind when the wrapper was constructed via a
233    /// `*_stamped` constructor; mirrored (and seeded) onto every
234    /// morph-allocated backing so the ordering axis survives
235    /// capacity morphs.
236    stamped: Option<StampKind>,
237    /// Serialises concurrent `morph_capacity_to` callers.
238    morph_lock: Mutex<()>,
239    /// One-slot warm cache: a fully constructed (and stamped, when
240    /// the wrapper is stamped) backing at a predicted
241    /// (capacity, locale), built off the morph lock by
242    /// [`prewarm`](Self::prewarm) / [`prewarm_config`](Self::prewarm_config).
243    /// The morph takes it when both key components match the morph
244    /// target, skipping allocation + mapping + zeroing on the
245    /// critical path. Shape is deliberately NOT part of the key:
246    /// fresh backings start SPSC and the swap path's shape morph
247    /// on an empty backing costs microseconds. A wrong prediction
248    /// stays in the slot until the next prewarm replaces it or
249    /// the wrapper drops.
250    warm: Mutex<Option<(usize, BackingTarget, Arc<AdaptiveRing>)>>,
251    /// Successful warm-cache hits consumed by capacity morphs.
252    warm_hits: AtomicU64,
253    /// Items the consumer popped from stale (post-morph) backings
254    /// rather than the active one. Observability for transition
255    /// cost; incremented only on the stale-walk pop path, never on
256    /// the steady-state active path.
257    stale_pops: AtomicU64,
258}
259
260/// Atomic snapshot of the ring's active backing + stale list.
261/// Held behind an `ArcSwap` on [`CapacityAdaptiveRing`] so the
262/// hot path is a single Acquire load: producers go straight to
263/// `state.active`, consumers walk `state.stale` then fall
264/// through to `state.active`. Morph constructs a new `RingState`
265/// and swaps the whole thing atomically.
266struct RingState {
267    /// The currently-active backing. Producers write here.
268    active: Arc<AdaptiveRing>,
269    /// Post-morph backings the consumer is still draining;
270    /// oldest-first. Pruned of empty entries by the next morph.
271    /// Producers never write to these (they only see `active`
272    /// via the load).
273    stale: Vec<Arc<AdaptiveRing>>,
274}
275
276unsafe impl Send for CapacityAdaptiveRing {}
277unsafe impl Sync for CapacityAdaptiveRing {}
278
279impl CapacityAdaptiveRing {
280    /// Anon (in-process) capacity-adaptive ring. The active backing
281    /// is an anonymous mmap; subsequent morphs allocate fresh anon
282    /// mmaps at the new capacity and drop the prior one once
283    /// stragglers drain.
284    pub fn create_anon(
285        max_producers: usize,
286        max_consumers: usize,
287        initial_capacity: usize,
288    ) -> Result<Self, CapacityMorphError> {
289        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
290            return Err(CapacityMorphError::InvalidCapacity);
291        }
292        let ring = AdaptiveRing::create_anon(
293            max_producers,
294            max_consumers,
295            initial_capacity,
296        )?;
297        Ok(Self {
298            state: ArcSwap::from(Arc::new(RingState {
299                active: Arc::new(ring),
300                stale: Vec::new(),
301            })),
302            pin_generation: AtomicU64::new(0),
303            capacity_atom: AtomicU64::new(initial_capacity as u64),
304            max_producers,
305            max_consumers,
306            backing_source: Mutex::new(BackingTarget::Anon),
307            morph_seq: AtomicU64::new(0),
308            stamped: None,
309            morph_lock: Mutex::new(()),
310            warm: Mutex::new(None),
311            warm_hits: AtomicU64::new(0),
312            stale_pops: AtomicU64::new(0),
313        })
314    }
315
316    /// As [`create_anon`](Self::create_anon) with ordering stamps
317    /// on the backing (and on every backing subsequent capacity
318    /// morphs allocate). See
319    /// [`AdaptiveRing::with_ordering_stamps`].
320    pub fn create_anon_stamped(
321        max_producers: usize,
322        max_consumers: usize,
323        initial_capacity: usize,
324    ) -> Result<Self, CapacityMorphError> {
325        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
326            return Err(CapacityMorphError::InvalidCapacity);
327        }
328        let kind = default_stamp_kind();
329        let ring = AdaptiveRing::create_anon(
330            max_producers, max_consumers, initial_capacity,
331        )?
332        .with_ordering_stamps_kind(kind)
333        .map_err(CapacityMorphError::Ring)?;
334        Ok(Self {
335            state: ArcSwap::from(Arc::new(RingState {
336                active: Arc::new(ring),
337                stale: Vec::new(),
338            })),
339            pin_generation: AtomicU64::new(0),
340            capacity_atom: AtomicU64::new(initial_capacity as u64),
341            max_producers,
342            max_consumers,
343            backing_source: Mutex::new(BackingTarget::Anon),
344            morph_seq: AtomicU64::new(0),
345            stamped: Some(kind),
346            morph_lock: Mutex::new(()),
347            warm: Mutex::new(None),
348            warm_hits: AtomicU64::new(0),
349            stale_pops: AtomicU64::new(0),
350        })
351    }
352
353    /// File-backed capacity-adaptive ring. The active backing is
354    /// `{base_path}.cap_{initial_capacity}.bin`; morphs allocate
355    /// fresh files at the morph target's suffix and drop the prior
356    /// file once stragglers drain.
357    pub fn create(
358        base_path: impl AsRef<Path>,
359        max_producers: usize,
360        max_consumers: usize,
361        initial_capacity: usize,
362    ) -> Result<Self, CapacityMorphError> {
363        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
364            return Err(CapacityMorphError::InvalidCapacity);
365        }
366        let base = base_path.as_ref().to_path_buf();
367        let path = path_for_capacity(&base, initial_capacity);
368        let ring = AdaptiveRing::create(
369            &path,
370            max_producers,
371            max_consumers,
372            initial_capacity,
373        )?;
374        Ok(Self {
375            state: ArcSwap::from(Arc::new(RingState {
376                active: Arc::new(ring),
377                stale: Vec::new(),
378            })),
379            pin_generation: AtomicU64::new(0),
380            capacity_atom: AtomicU64::new(initial_capacity as u64),
381            max_producers,
382            max_consumers,
383            backing_source: Mutex::new(BackingTarget::File(base)),
384            morph_seq: AtomicU64::new(0),
385            stamped: None,
386            morph_lock: Mutex::new(()),
387            warm: Mutex::new(None),
388            warm_hits: AtomicU64::new(0),
389            stale_pops: AtomicU64::new(0),
390        })
391    }
392
393    /// As [`create`](Self::create) with ordering stamps on the
394    /// backing and every morph-allocated successor.
395    pub fn create_stamped(
396        base_path: impl AsRef<Path>,
397        max_producers: usize,
398        max_consumers: usize,
399        initial_capacity: usize,
400    ) -> Result<Self, CapacityMorphError> {
401        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
402            return Err(CapacityMorphError::InvalidCapacity);
403        }
404        let kind = default_stamp_kind();
405        let base = base_path.as_ref().to_path_buf();
406        let path = path_for_capacity(&base, initial_capacity);
407        let ring = AdaptiveRing::create(
408            &path, max_producers, max_consumers, initial_capacity,
409        )?
410        .with_ordering_stamps_kind(kind)
411        .map_err(CapacityMorphError::Ring)?;
412        Ok(Self {
413            state: ArcSwap::from(Arc::new(RingState {
414                active: Arc::new(ring),
415                stale: Vec::new(),
416            })),
417            pin_generation: AtomicU64::new(0),
418            capacity_atom: AtomicU64::new(initial_capacity as u64),
419            max_producers,
420            max_consumers,
421            backing_source: Mutex::new(BackingTarget::File(base)),
422            morph_seq: AtomicU64::new(0),
423            stamped: Some(kind),
424            morph_lock: Mutex::new(()),
425            warm: Mutex::new(None),
426            warm_hits: AtomicU64::new(0),
427            stale_pops: AtomicU64::new(0),
428        })
429    }
430
431    /// ShmFs (named shared memory) capacity-adaptive ring. The
432    /// active backing is named `{name_prefix}_cap_{initial_capacity}`;
433    /// morphs allocate fresh named-shm regions at the morph
434    /// target's suffix and drop the prior region once stragglers
435    /// drain. Cross-process visible: another process opens the
436    /// same logical ring by constructing a CapacityAdaptiveRing
437    /// with the same `name_prefix`.
438    pub fn create_shmfs(
439        name_prefix: &str,
440        max_producers: usize,
441        max_consumers: usize,
442        initial_capacity: usize,
443    ) -> Result<Self, CapacityMorphError> {
444        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
445            return Err(CapacityMorphError::InvalidCapacity);
446        }
447        let name = format!("{name_prefix}_cap_{initial_capacity}");
448        let ring = AdaptiveRing::create_shmfs(
449            &name,
450            max_producers,
451            max_consumers,
452            initial_capacity,
453        )?;
454        Ok(Self {
455            state: ArcSwap::from(Arc::new(RingState {
456                active: Arc::new(ring),
457                stale: Vec::new(),
458            })),
459            pin_generation: AtomicU64::new(0),
460            capacity_atom: AtomicU64::new(initial_capacity as u64),
461            max_producers,
462            max_consumers,
463            backing_source: Mutex::new(BackingTarget::Shm(name_prefix.to_owned())),
464            morph_seq: AtomicU64::new(0),
465            stamped: None,
466            morph_lock: Mutex::new(()),
467            warm: Mutex::new(None),
468            warm_hits: AtomicU64::new(0),
469            stale_pops: AtomicU64::new(0),
470        })
471    }
472
473    /// As [`create_shmfs`](Self::create_shmfs) with ordering stamps
474    /// on the backing and every morph-allocated successor.
475    pub fn create_shmfs_stamped(
476        name_prefix: &str,
477        max_producers: usize,
478        max_consumers: usize,
479        initial_capacity: usize,
480    ) -> Result<Self, CapacityMorphError> {
481        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
482            return Err(CapacityMorphError::InvalidCapacity);
483        }
484        let kind = default_stamp_kind();
485        let name = format!("{name_prefix}_cap_{initial_capacity}");
486        let ring = AdaptiveRing::create_shmfs(
487            &name, max_producers, max_consumers, initial_capacity,
488        )?
489        .with_ordering_stamps_kind(kind)
490        .map_err(CapacityMorphError::Ring)?;
491        Ok(Self {
492            state: ArcSwap::from(Arc::new(RingState {
493                active: Arc::new(ring),
494                stale: Vec::new(),
495            })),
496            pin_generation: AtomicU64::new(0),
497            capacity_atom: AtomicU64::new(initial_capacity as u64),
498            max_producers,
499            max_consumers,
500            backing_source: Mutex::new(BackingTarget::Shm(name_prefix.to_owned())),
501            morph_seq: AtomicU64::new(0),
502            stamped: Some(kind),
503            morph_lock: Mutex::new(()),
504            warm: Mutex::new(None),
505            warm_hits: AtomicU64::new(0),
506            stale_pops: AtomicU64::new(0),
507        })
508    }
509
510    /// Current capacity of the active backing. Stays in lockstep
511    /// with the active ArcSwap; observers see the value the morph
512    /// publishes via a Release store.
513    pub fn current_capacity(&self) -> usize {
514        self.capacity_atom.load(Ordering::Acquire) as usize
515    }
516
517    /// Current pin generation. Pinned handles capture this at pin
518    /// time; a different live value means the pin is stale.
519    pub fn pin_generation(&self) -> u64 {
520        self.pin_generation.load(Ordering::Acquire)
521    }
522
523    /// Register a producer on the active backing. The returned id
524    /// is valid only against the current capacity backing; after a
525    /// morph the caller re-registers against the new active backing
526    /// (mirrored automatically by `morph_capacity_to`).
527    pub fn register_producer(&self) -> Result<usize, AdaptiveError> {
528        self.state.load().active.register_producer()
529    }
530
531    /// Register a consumer on the active backing. Same lifetime
532    /// caveat as `register_producer`.
533    pub fn register_consumer(&self) -> Result<usize, AdaptiveError> {
534        self.state.load().active.register_consumer()
535    }
536
537    /// Hot-path push. One ArcSwap load + the active backing's
538    /// dispatched `try_send`.
539    #[inline]
540    pub fn try_send(
541        &self,
542        producer_id: usize,
543        payload: &[u8],
544    ) -> Result<(), RingError> {
545        self.state.load().active.try_send(producer_id, payload)
546    }
547
548    /// Hot-path pop. Walks the stale backing list oldest-first,
549    /// returning the first non-empty backing's item; falls through
550    /// to the active backing when every stale entry is empty.
551    ///
552    /// The consumer is the SOLE reader of every backing (stale +
553    /// active). Producers only ever write to active. This is what
554    /// preserves the SPSC contract on the per-backing
555    /// `SpscRingCore`: exactly one consumer touches it, even
556    /// across morph boundaries.
557    ///
558    /// FIFO ordering invariant: stale and active are captured
559    /// under the SAME mutex acquisition (the stale lock). This
560    /// prevents the race where a morph slips in between the
561    /// stale-snapshot and the active-load and the consumer ends
562    /// up reading from the new active while the old active sits
563    /// in the new stale tail unread - which would reorder items
564    /// the producer pushed to the soon-to-be-stale ring AFTER
565    /// items the producer pushed to the brand-new active.
566    #[inline]
567    pub fn try_recv(
568        &self,
569        consumer_id: usize,
570        out: &mut [u8],
571    ) -> Result<usize, RingError> {
572        // One ArcSwap load gives us a consistent snapshot of
573        // BOTH stale and active. The wrapper does no mutex
574        // acquisition on the hot path.
575        //
576        // Per-stale-ring spin discipline (FIFO correctness):
577        // walking a stale ring may observe `Err(Empty)` in two
578        // distinct cases:
579        //
580        //   (a) ring's consumer_seq >= producer_seq - truly
581        //       drained for this consumer; safe to advance.
582        //   (b) ring's consumer_seq < producer_seq AND the slot
583        //       at consumer_seq is mid-claim (producer has CAS'd
584        //       producer_seq forward but not yet stored the
585        //       payload, OR another consumer is mid-claim on the
586        //       same slot under MPMC) - NOT empty; advancing now
587        //       and reading from `active` would let this consumer
588        //       consume a higher-producer-index item from `active`
589        //       before the lower-producer-index item from this
590        //       stale ring becomes available, violating per-
591        //       consumer per-producer FIFO.
592        //
593        // The fix: on Err from a stale ring, check `is_empty()`
594        // (which compares producer_seq == consumer_seq, NOT
595        // slot-sequence). If truly empty, advance. Otherwise spin
596        // and retry on the same stale ring until the in-flight
597        // claim commits (bounded by producer commit latency).
598        let state = self.state.load();
599        for ring in &state.stale {
600            loop {
601                match ring.try_recv(consumer_id, out) {
602                    Ok(n) => {
603                        self.stale_pops.fetch_add(1, Ordering::Relaxed);
604                        return Ok(n);
605                    }
606                    Err(_) => {
607                        if ring.is_empty() {
608                            break;
609                        }
610                        std::hint::spin_loop();
611                    }
612                }
613            }
614        }
615        state.active.try_recv(consumer_id, out)
616    }
617
618    /// Morph the ring's capacity to `new_capacity`. Allocates a
619    /// fresh backing at the new size, bumps `pin_generation`,
620    /// stashes the old backing onto the `stale` list (the
621    /// consumer drains it via `try_recv`'s stale-walk), and
622    /// atomic-swaps the active pointer. Concurrent morphs are
623    /// serialised through an internal mutex; hot-path ops are not
624    /// blocked.
625    ///
626    /// Critically the morph DOES NOT drain the old backing - that
627    /// would race against the consumer's concurrent `try_recv` on
628    /// the same backing, violating the per-backing
629    /// SPSC/MPSC/MPMC contract (two consumers on an SPSC ring is
630    /// undefined behavior). Instead the old backing stays
631    /// reachable via the stale list; the consumer is the sole
632    /// reader and pops every in-flight item via `try_recv`'s
633    /// stale-walk-then-active pattern.
634    ///
635    /// Shrink always succeeds. In-flight items physically remain
636    /// in the old (larger) backing as part of the stale list; the
637    /// new capacity governs only items the producer pushes after
638    /// the morph. Memory holds both old + new backings until the
639    /// consumer drains old, at which point the next morph prunes
640    /// the empty old entry from the stale list.
641    pub fn morph_capacity_to(
642        &self,
643        new_capacity: usize,
644    ) -> Result<(), CapacityMorphError> {
645        self.morph_to_config(&RingConfig {
646            capacity: Some(new_capacity),
647            ..RingConfig::default()
648        })
649    }
650
651    /// Compound morph: change any subset of {shape, capacity,
652    /// locale} in ONE transition. Builds a single fresh backing at
653    /// the combined target (warm-cache hit when
654    /// [`prewarm_config`](Self::prewarm_config) predicted it),
655    /// seeds stamps, mirrors registrations once, applies the
656    /// target shape to the empty new backing, bumps the pin
657    /// generation once, and appends the displaced active to the
658    /// stale list once - however many axes changed. A sequential
659    /// walk of the same axes pays each of those costs per axis.
660    ///
661    /// Special cases:
662    /// - Every axis already at target: no-op, no generation bump.
663    /// - Shape-only change (capacity + locale unchanged):
664    ///   delegates to the active backing's in-place shape morph
665    ///   (all four shape protocols are pre-allocated inside
666    ///   `AdaptiveRing`), so no fresh backing is built, the
667    ///   wrapper pin stays valid, and in-flight items stay put.
668    /// - A locale axis retargets the wrapper's [`BackingTarget`]
669    ///   for this morph AND every subsequent morph / prewarm.
670    pub fn morph_to_config(
671        &self,
672        target: &RingConfig,
673    ) -> Result<(), CapacityMorphError> {
674        let _morph_guard = self.morph_lock.lock();
675
676        let old_state = self.state.load_full();
677        let old = Arc::clone(&old_state.active);
678        let old_capacity = self.capacity_atom.load(Ordering::Acquire) as usize;
679        let old_shape = old.current_shape();
680        let old_locale = self.backing_source.lock().clone();
681
682        let new_capacity = target.capacity.unwrap_or(old_capacity);
683        if !new_capacity.is_power_of_two() || new_capacity < 2 {
684            return Err(CapacityMorphError::InvalidCapacity);
685        }
686        let new_shape = target.shape.unwrap_or(old_shape);
687        let new_locale =
688            target.locale.clone().unwrap_or_else(|| old_locale.clone());
689
690        if new_capacity == old_capacity
691            && new_shape == old_shape
692            && new_locale == old_locale
693        {
694            return Ok(());
695        }
696
697        // Shape-only: in-place morph on the active backing. No
698        // fresh backing, no wrapper pin invalidation, no stale
699        // entry - AdaptiveRing pre-allocates all four shape
700        // protocols and handles its own transition.
701        if new_capacity == old_capacity && new_locale == old_locale {
702            return old.morph_to(new_shape).map_err(CapacityMorphError::Ring);
703        }
704
705        // Publish a locale retarget before building so this build
706        // and every later one allocate at the new locale.
707        if new_locale != old_locale {
708            *self.backing_source.lock() = new_locale.clone();
709        }
710
711        // Warm-cache probe: one uncontended lock + Option take. A
712        // prediction matching the morph target's (capacity, locale)
713        // skips allocation + mapping + zeroing entirely; a mismatch
714        // stays cached for a later morph and the cold path below
715        // runs unchanged.
716        let warm_hit = {
717            let mut warm = self.warm.lock();
718            warm.take_if(|(cap, loc, _)| {
719                *cap == new_capacity && *loc == new_locale
720            })
721        };
722        let new = match warm_hit {
723            Some((_, _, ring)) => {
724                self.warm_hits.fetch_add(1, Ordering::Relaxed);
725                ring
726            }
727            None => self.build_backing(new_capacity, &new_locale)?,
728        };
729
730        // Seed the ordering axis at swap time - counters move
731        // continuously, so seeding cannot happen at build time.
732        // The fresh region inherits the old one's counter stamps
733        // and live mode flag, keeping stamps monotone across the
734        // swap for warm and cold builds alike.
735        if self.stamped.is_some()
736            && let (Some(new_region), Some(old_region)) =
737                (new.ordering_region(), old.ordering_region())
738        {
739            new_region.seed_from(old_region);
740        }
741
742        // Mirror the producer/consumer registration counts so the
743        // new backing accepts ops against the same ids the old one
744        // accepted.
745        let n_producers = old.active_producers();
746        let n_consumers = old.active_consumers();
747        for _ in 0..n_producers {
748            new.register_producer()?;
749        }
750        for _ in 0..n_consumers {
751            new.register_consumer()?;
752        }
753
754        // Apply the target shape to the (empty, unobserved) new
755        // backing. Fresh and warm backings both start SPSC, so one
756        // call covers the keep-shape mirror AND the compound shape
757        // axis; registration counts alone never trigger a shape
758        // morph, and skipping this would silently drop an MPSC /
759        // MPMC / Vyukov ring back to SPSC on the new backing.
760        if new.current_shape() != new_shape {
761            new.morph_to(new_shape).map_err(CapacityMorphError::Ring)?;
762        }
763
764        // Bump the pin generation so outstanding pins invalidate.
765        self.pin_generation.fetch_add(1, Ordering::AcqRel);
766
767        // Build the new state in one shot: prune the old stale
768        // list (drop fully-drained entries), append the prior
769        // active onto the end, then publish atomically. Producers
770        // and consumers reading via `self.state.load()` see either
771        // the full old state or the full new state - never a
772        // half-state where active and stale disagree.
773        let mut new_stale: Vec<Arc<AdaptiveRing>> =
774            old_state.stale.iter().filter(|r| !r.is_empty()).cloned().collect();
775        new_stale.push(old);
776        let new_state = RingState { active: new, stale: new_stale };
777        self.state.store(Arc::new(new_state));
778
779        // Publish the new observable capacity.
780        self.capacity_atom
781            .store(new_capacity as u64, Ordering::Release);
782
783        Ok(())
784    }
785
786    /// Construct (and stamp, when the wrapper is stamped) a fresh
787    /// backing at `capacity`, at the wrapper's locale, with a
788    /// unique per-build name. Shared by the cold morph path and
789    /// [`prewarm`](Self::prewarm).
790    fn build_backing(
791        &self,
792        capacity: usize,
793        locale: &BackingTarget,
794    ) -> Result<Arc<AdaptiveRing>, CapacityMorphError> {
795        // Bump the morph sequence BEFORE allocating so file paths
796        // and shm names are unique even when callers cycle through
797        // the same capacities (the prior backing's file / shm
798        // region is still mapped from the stale list and cannot
799        // share its name with a new backing). Speculative builds
800        // that are never consumed burn a sequence number; gaps are
801        // harmless because the value only disambiguates names.
802        let seq = self.morph_seq.fetch_add(1, Ordering::AcqRel);
803        let mut ring = match locale {
804            BackingTarget::Anon => AdaptiveRing::create_anon(
805                self.max_producers,
806                self.max_consumers,
807                capacity,
808            )?,
809            BackingTarget::File(base) => AdaptiveRing::create(
810                path_for_capacity_seq(base, capacity, seq),
811                self.max_producers,
812                self.max_consumers,
813                capacity,
814            )?,
815            BackingTarget::Shm(prefix) => AdaptiveRing::create_shmfs(
816                &format!("{prefix}_cap_{capacity}_g{seq}"),
817                self.max_producers,
818                self.max_consumers,
819                capacity,
820            )?,
821        };
822        // Stamp at build time: stamping consumes the ring by
823        // value, so a cached warm backing must already carry its
824        // stamps. Seeding from the live region happens at swap
825        // time in `morph_to_config`.
826        if let Some(kind) = self.stamped {
827            ring = ring
828                .with_ordering_stamps_kind(kind)
829                .map_err(CapacityMorphError::Ring)?;
830        }
831        Ok(Arc::new(ring))
832    }
833
834    /// Speculatively build a backing at `capacity` (current
835    /// locale) into the one-slot warm cache, off the morph lock's
836    /// critical path. The next morph targeting that capacity
837    /// consumes it and skips allocation + mapping + zeroing.
838    pub fn prewarm(&self, capacity: usize) -> Result<(), CapacityMorphError> {
839        self.prewarm_config(&RingConfig {
840            capacity: Some(capacity),
841            ..RingConfig::default()
842        })
843    }
844
845    /// Speculatively build a backing at `target`'s (capacity,
846    /// locale) into the one-slot warm cache, off the morph lock's
847    /// critical path - the build half of a build-beside-and-
848    /// repatch transition: the following
849    /// [`morph_to_config`](Self::morph_to_config) at the same
850    /// target consumes it and pays only the swap. The shape axis
851    /// is ignored here: the swap path shapes the empty backing in
852    /// microseconds. Replaces any previously cached prediction
853    /// (the slot holds exactly one); re-prewarming the cached
854    /// (capacity, locale) is a no-op.
855    pub fn prewarm_config(
856        &self,
857        target: &RingConfig,
858    ) -> Result<(), CapacityMorphError> {
859        let capacity = target
860            .capacity
861            .unwrap_or_else(|| self.current_capacity());
862        if !capacity.is_power_of_two() || capacity < 2 {
863            return Err(CapacityMorphError::InvalidCapacity);
864        }
865        let locale = target
866            .locale
867            .clone()
868            .unwrap_or_else(|| self.backing_source.lock().clone());
869        if self
870            .warm
871            .lock()
872            .as_ref()
873            .is_some_and(|(c, l, _)| *c == capacity && *l == locale)
874        {
875            return Ok(());
876        }
877        // Build WITHOUT holding the warm lock - a large file-backed
878        // build takes milliseconds and the lock is probed by every
879        // morph. Concurrent prewarms race benignly: last store wins.
880        let ring = self.build_backing(capacity, &locale)?;
881        *self.warm.lock() = Some((capacity, locale, ring));
882        Ok(())
883    }
884
885    /// Capacity currently held in the warm cache, if any.
886    pub fn warm_capacity(&self) -> Option<usize> {
887        self.warm.lock().as_ref().map(|(c, _, _)| *c)
888    }
889
890    /// Number of morphs that consumed a warm-cache prediction.
891    pub fn warm_hits(&self) -> u64 {
892        self.warm_hits.load(Ordering::Relaxed)
893    }
894
895    /// Items the consumer popped from stale (post-morph) backings
896    /// rather than the active one, since construction. The
897    /// transition-cost observability counterpart to `warm_hits`.
898    pub fn stale_pops(&self) -> u64 {
899        self.stale_pops.load(Ordering::Relaxed)
900    }
901
902    /// Drop any cached prediction, releasing its memory (and its
903    /// file / shm region for non-anon locales).
904    pub fn clear_warm(&self) {
905        *self.warm.lock() = None;
906    }
907
908    /// Pin the current capacity backing for a hot loop. The
909    /// returned [`PinnedCapacity`] exposes the underlying
910    /// [`AdaptiveRing`] directly and validates
911    /// against the pin generation via
912    /// [`is_still_valid`](PinnedCapacity::is_still_valid).
913    pub fn pin_current_capacity(&self) -> PinnedCapacity<'_> {
914        let captured_gen = self.pin_generation.load(Ordering::Acquire);
915        let ring = Arc::clone(&self.state.load().active);
916        let capacity = self.capacity_atom.load(Ordering::Acquire) as usize;
917        PinnedCapacity {
918            parent: self,
919            pinned_generation: captured_gen,
920            ring,
921            capacity,
922            _not_sync: std::marker::PhantomData,
923        }
924    }
925
926    /// Direct access to the active [`AdaptiveRing`].
927    /// Override hatch for callers that want the shape-axis surface
928    /// on top of the capacity-axis morphing.
929    pub fn ring_handle(&self) -> Arc<AdaptiveRing> {
930        Arc::clone(&self.state.load().active)
931    }
932
933    /// Whether this wrapper's backings carry ordering stamps.
934    pub fn is_stamped(&self) -> bool {
935        self.stamped.is_some()
936    }
937
938    /// Live ordering mode of the active backing (`None` when
939    /// unstamped).
940    pub fn ordering_mode(&self) -> Option<OrderingMode> {
941        self.state.load().active.ordering_mode()
942    }
943
944    /// Flip the ordering mode across the active backing AND every
945    /// stale backing still draining, so the consumer's
946    /// stale-walk-then-active pop applies one consistent discipline.
947    /// Cross-backing order note: producers only ever write to the
948    /// active backing, so every stale item predates every active
949    /// item - the stale-oldest-first walk composes with per-backing
950    /// stamp merging into global stamp order across the morph
951    /// boundary (within the stamp source's skew window).
952    pub fn set_ordering_mode(&self, mode: OrderingMode) -> Result<(), RingError> {
953        let state = self.state.load();
954        for ring in &state.stale {
955            ring.set_ordering_mode(mode)?;
956        }
957        state.active.set_ordering_mode(mode)
958    }
959
960    /// Cross-producer inversions observed on the active backing.
961    /// Continuous across capacity morphs: each morph seeds the
962    /// fresh region's counter from the old one.
963    pub fn inversions(&self) -> u64 {
964        self.state.load().active.inversions()
965    }
966}
967
968/// Pinned snapshot of a [`CapacityAdaptiveRing`]'s current
969/// capacity backing. `Send` (an Arc lifetime extension), `!Sync`
970/// (single-owner-at-a-time semantics via [`std::cell::Cell`]
971/// marker).
972pub struct PinnedCapacity<'a> {
973    parent: &'a CapacityAdaptiveRing,
974    pinned_generation: u64,
975    ring: Arc<AdaptiveRing>,
976    capacity: usize,
977    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
978}
979
980impl<'a> PinnedCapacity<'a> {
981    /// Whether this pin's capacity backing is still the active
982    /// backing. One Acquire load on the parent's generation atom.
983    pub fn is_still_valid(&self) -> bool {
984        self.parent.pin_generation.load(Ordering::Acquire) == self.pinned_generation
985    }
986
987    /// Capacity captured at pin time.
988    pub fn capacity(&self) -> usize { self.capacity }
989
990    /// Pin generation captured at pin time.
991    pub fn generation(&self) -> u64 { self.pinned_generation }
992
993    /// Direct access to the pinned [`AdaptiveRing`].
994    pub fn ring(&self) -> &Arc<AdaptiveRing> { &self.ring }
995}
996
997/// Compose the file path for a given capacity (initial-backing
998/// form). Used at constructor time when no morph has happened yet
999/// so no per-morph sequence number exists.
1000fn path_for_capacity(base: &Path, capacity: usize) -> PathBuf {
1001    let mut s = base.as_os_str().to_owned();
1002    s.push(format!(".cap_{capacity}.bin"));
1003    PathBuf::from(s)
1004}
1005
1006/// Compose the per-morph file path. The `seq` disambiguates
1007/// successive morphs that revisit the same capacity (e.g. cycling
1008/// 256 -> 1024 -> 256 -> 1024 ...) so the prior backing's file
1009/// can sit in the stale list while the new one allocates without
1010/// a path collision.
1011fn path_for_capacity_seq(base: &Path, capacity: usize, seq: u64) -> PathBuf {
1012    let mut s = base.as_os_str().to_owned();
1013    s.push(format!(".cap_{capacity}_g{seq}.bin"));
1014    PathBuf::from(s)
1015}
1016
1017// ===================================================================
1018// Sidecar capacity policy: automatic morphing based on fill-ratio
1019// observations, mirroring the shape-morph
1020// `AdaptiveRingSidecar` / `DefaultRingShapePolicy` design.
1021// ===================================================================
1022
1023/// A snapshot of the capacity-adaptive ring's observable state
1024/// passed to a [`CapacityPolicy`] on every sidecar scan.
1025#[derive(Debug, Clone, Copy)]
1026pub struct CapacityPolicyObservation {
1027    /// Current capacity of the active backing (slots per sub-ring,
1028    /// per the underlying SPSC / MPSC / MPMC / Vyukov shape).
1029    pub current_capacity: usize,
1030    /// Approximate item count across every sub-ring of the active
1031    /// backing right now (sum over per-producer rings for composed
1032    /// shapes; a single ring's depth for SPSC / Vyukov).
1033    pub active_approx_len: usize,
1034    /// Total slot inventory the producer can fill before back-
1035    /// pressure. Equals `current_capacity` for SPSC / Vyukov;
1036    /// `current_capacity * n_sub_rings` for composed shapes.
1037    pub total_slot_capacity: usize,
1038    /// Time since the last successful capacity morph. Used by the
1039    /// policy to suppress thrashing via hysteresis.
1040    pub since_last_morph: std::time::Duration,
1041}
1042
1043impl CapacityPolicyObservation {
1044    /// Convenience accessor: `active_approx_len / total_slot_capacity`
1045    /// clamped to `[0.0, 1.0]`. Policy logic typically branches on
1046    /// this against a `grow_at` upper threshold and a `shrink_at`
1047    /// lower threshold.
1048    pub fn fill_ratio(&self) -> f64 {
1049        if self.total_slot_capacity == 0 {
1050            return 0.0;
1051        }
1052        let ratio = self.active_approx_len as f64 / self.total_slot_capacity as f64;
1053        if ratio > 1.0 { 1.0 } else { ratio }
1054    }
1055}
1056
1057/// Policy that decides when (and to what new capacity) the sidecar
1058/// should grow / shrink the
1059/// [`CapacityAdaptiveRing`]. Returning `Some(new_capacity)`
1060/// triggers `morph_capacity_to(new_capacity)`. Returning `None`
1061/// leaves the capacity alone.
1062pub trait CapacityPolicy: Send + Sync + 'static {
1063    fn decide(&self, observation: &CapacityPolicyObservation) -> Option<usize>;
1064
1065    /// Capacity the policy expects `decide` to request soon, used
1066    /// by the sidecar to pre-build the backing off the morph
1067    /// lock's critical path ([`CapacityAdaptiveRing::prewarm`]).
1068    /// Purely speculative: a prediction never changes WHAT the
1069    /// ring morphs to, only how fast the morph executes when the
1070    /// prediction was right. The default returns `None`, so
1071    /// existing policy impls keep their behavior unchanged.
1072    fn predict(&self, _observation: &CapacityPolicyObservation) -> Option<usize> {
1073        None
1074    }
1075}
1076
1077/// Default capacity policy: fill-ratio with hysteresis.
1078///
1079/// On every scan the sidecar computes `fill_ratio = approx_len /
1080/// total_capacity`. If `fill_ratio >= grow_at`, the policy doubles
1081/// the capacity (up to `max_capacity`). If `fill_ratio <=
1082/// shrink_at`, the policy halves the capacity (down to
1083/// `min_capacity`). Otherwise it returns `None`.
1084///
1085/// Suppressed for `since_last_morph < hysteresis` to prevent
1086/// thrashing under bursty load. Default hysteresis 100 ms matches
1087/// the shape-morph policy.
1088pub struct DefaultCapacityPolicy {
1089    /// Upper fill-ratio that triggers a grow. Default 0.85.
1090    pub grow_at: f64,
1091    /// Lower fill-ratio that triggers a shrink. Default 0.10.
1092    pub shrink_at: f64,
1093    /// Minimum allowed capacity (pow2 >= 2). Default 64.
1094    pub min_capacity: usize,
1095    /// Maximum allowed capacity (pow2). Default 65536.
1096    pub max_capacity: usize,
1097    /// Cooldown after each morph. Default 100 ms.
1098    pub hysteresis: std::time::Duration,
1099}
1100
1101impl Default for DefaultCapacityPolicy {
1102    fn default() -> Self {
1103        Self {
1104            grow_at: 0.85,
1105            shrink_at: 0.10,
1106            min_capacity: 64,
1107            max_capacity: 65536,
1108            hysteresis: std::time::Duration::from_millis(100),
1109        }
1110    }
1111}
1112
1113impl CapacityPolicy for DefaultCapacityPolicy {
1114    fn decide(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1115        if obs.since_last_morph < self.hysteresis {
1116            return None;
1117        }
1118        let ratio = obs.fill_ratio();
1119        if ratio >= self.grow_at && obs.current_capacity < self.max_capacity {
1120            Some((obs.current_capacity * 2).min(self.max_capacity))
1121        } else if ratio <= self.shrink_at && obs.current_capacity > self.min_capacity {
1122            Some((obs.current_capacity / 2).max(self.min_capacity))
1123        } else {
1124            None
1125        }
1126    }
1127
1128    /// Predicts the doubled capacity once the fill ratio crosses
1129    /// 75% of the grow threshold, and the halved capacity once it
1130    /// falls under 150% of the shrink threshold - the trend bands
1131    /// in front of the decide thresholds. Deliberately NOT gated
1132    /// on hysteresis: the cooldown window after a morph is exactly
1133    /// the right time to build the next predicted backing.
1134    fn predict(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1135        let ratio = obs.fill_ratio();
1136        if ratio >= self.grow_at * 0.75 && obs.current_capacity < self.max_capacity {
1137            Some((obs.current_capacity * 2).min(self.max_capacity))
1138        } else if ratio <= self.shrink_at * 1.5 && obs.current_capacity > self.min_capacity {
1139            Some((obs.current_capacity / 2).max(self.min_capacity))
1140        } else {
1141            None
1142        }
1143    }
1144}
1145
1146/// Background scanner thread that drives capacity morphs on a
1147/// [`CapacityAdaptiveRing`] from a [`CapacityPolicy`].
1148///
1149/// `spawn` starts the thread; `shutdown` stops it. The thread
1150/// scans every `scan_interval`, builds a
1151/// [`CapacityPolicyObservation`], asks the policy, and calls
1152/// [`CapacityAdaptiveRing::morph_capacity_to`] on `Some(new_capacity)`
1153/// responses. Successful morphs increment the per-sidecar
1154/// `morphs_triggered` counter.
1155pub struct CapacityAdaptiveRingSidecar {
1156    handle: Option<std::thread::JoinHandle<()>>,
1157    stop: Arc<std::sync::atomic::AtomicBool>,
1158    morphs_triggered: Arc<AtomicU64>,
1159    prewarms_issued: Arc<AtomicU64>,
1160}
1161
1162impl CapacityAdaptiveRingSidecar {
1163    /// Spawn a sidecar thread that morphs `ring` according to
1164    /// `policy` decisions sampled every `scan_interval`.
1165    ///
1166    /// Prediction wiring: when `policy.predict` names the same
1167    /// target on two consecutive scans (a sustained trend, not a
1168    /// one-scan blip), the sidecar pre-builds that backing via
1169    /// [`CapacityAdaptiveRing::prewarm`] - off the morph lock, on
1170    /// this thread's idle time - so the eventual `decide`-driven
1171    /// morph consumes it instead of allocating on the critical
1172    /// path. Policies whose `predict` returns `None` (the trait
1173    /// default) get today's behavior exactly.
1174    pub fn spawn<P: CapacityPolicy>(
1175        ring: Arc<CapacityAdaptiveRing>,
1176        policy: P,
1177        scan_interval: std::time::Duration,
1178    ) -> Self {
1179        Self::spawn_gated(ring, policy, scan_interval, crate::policy_gate::GateConfig::default())
1180    }
1181
1182    /// As [`spawn`](Self::spawn) with a confidence gate between the
1183    /// policy's recommendation and the morph. With
1184    /// `gate_cfg.enabled == false` (the default) behavior is
1185    /// identical to `spawn`. Enabled, a recommendation must hold
1186    /// across consecutive scans until conviction crosses the
1187    /// gate's threshold (and any sample floor); recommendation
1188    /// reversals, peer-count changes, and fill-ratio jumps collapse
1189    /// conviction, so oscillating load starves the gate instead of
1190    /// thrashing the ring.
1191    pub fn spawn_gated<P: CapacityPolicy>(
1192        ring: Arc<CapacityAdaptiveRing>,
1193        policy: P,
1194        scan_interval: std::time::Duration,
1195        gate_cfg: crate::policy_gate::GateConfig,
1196    ) -> Self {
1197        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1198        let morphs_triggered = Arc::new(AtomicU64::new(0));
1199        let prewarms_issued = Arc::new(AtomicU64::new(0));
1200
1201        let stop_c = Arc::clone(&stop);
1202        let morphs_c = Arc::clone(&morphs_triggered);
1203        let prewarms_c = Arc::clone(&prewarms_issued);
1204        let handle = std::thread::spawn(move || {
1205            let mut last_morph = std::time::Instant::now();
1206            let mut last_predicted: Option<usize> = None;
1207            let mut gate = crate::policy_gate::ConfidenceGate::new(gate_cfg);
1208            let mut last_peers = (0usize, 0usize);
1209            let mut last_fill = 0.0f64;
1210            let mut first_scan = true;
1211            while !stop_c.load(Ordering::Acquire) {
1212                let active = ring.ring_handle();
1213                let obs = CapacityPolicyObservation {
1214                    current_capacity: ring.current_capacity(),
1215                    active_approx_len: active.approx_len(),
1216                    total_slot_capacity: active.total_slot_capacity(),
1217                    since_last_morph: last_morph.elapsed(),
1218                };
1219                let peers = (active.active_producers(), active.active_consumers());
1220                drop(active);
1221
1222                // Regime-shift signals collapse conviction: the
1223                // workload changed character, so any accumulated
1224                // agreement belongs to the old regime.
1225                let fill = obs.fill_ratio();
1226                if !first_scan {
1227                    if peers != last_peers {
1228                        gate.shock();
1229                    }
1230                    if (fill - last_fill).abs() > 0.5 {
1231                        gate.shock();
1232                    }
1233                }
1234                last_peers = peers;
1235                last_fill = fill;
1236                first_scan = false;
1237
1238                if let Some(new_cap) = gate.observe(policy.decide(&obs))
1239                    && ring.morph_capacity_to(new_cap).is_ok()
1240                {
1241                    last_morph = std::time::Instant::now();
1242                    morphs_c.fetch_add(1, Ordering::Relaxed);
1243                }
1244                match policy.predict(&obs) {
1245                    Some(target) if target != ring.current_capacity() => {
1246                        // Two consecutive scans naming the same
1247                        // target = a sustained trend; build it.
1248                        if last_predicted == Some(target)
1249                            && ring.warm_capacity() != Some(target)
1250                            && ring.prewarm(target).is_ok()
1251                        {
1252                            prewarms_c.fetch_add(1, Ordering::Relaxed);
1253                        }
1254                        last_predicted = Some(target);
1255                    }
1256                    _ => last_predicted = None,
1257                }
1258                std::thread::sleep(scan_interval);
1259            }
1260        });
1261
1262        Self { handle: Some(handle), stop, morphs_triggered, prewarms_issued }
1263    }
1264
1265    /// Number of successful morphs triggered by this sidecar
1266    /// since `spawn`.
1267    pub fn morphs_triggered(&self) -> u64 {
1268        self.morphs_triggered.load(Ordering::Relaxed)
1269    }
1270
1271    /// Number of speculative backings this sidecar pre-built via
1272    /// `predict` trends since `spawn`.
1273    pub fn prewarms_issued(&self) -> u64 {
1274        self.prewarms_issued.load(Ordering::Relaxed)
1275    }
1276
1277    /// Stop the sidecar thread and wait for it to exit.
1278    pub fn shutdown(mut self) {
1279        self.stop.store(true, Ordering::Release);
1280        if let Some(h) = self.handle.take() {
1281            drop(h.join());
1282        }
1283    }
1284}
1285
1286impl Drop for CapacityAdaptiveRingSidecar {
1287    fn drop(&mut self) {
1288        self.stop.store(true, Ordering::Release);
1289        if let Some(h) = self.handle.take() {
1290            drop(h.join());
1291        }
1292    }
1293}
1294
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299
1300    #[test]
1301    fn create_anon_rejects_non_pow2() {
1302        let r = CapacityAdaptiveRing::create_anon(1, 1, 100);
1303        assert!(matches!(r, Err(CapacityMorphError::InvalidCapacity)));
1304    }
1305
1306    #[test]
1307    fn create_anon_rejects_capacity_below_two() {
1308        let r = CapacityAdaptiveRing::create_anon(1, 1, 1);
1309        assert!(matches!(r, Err(CapacityMorphError::InvalidCapacity)));
1310    }
1311
1312    #[test]
1313    fn anon_round_trip_after_create() {
1314        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1315        ring.register_producer().unwrap();
1316        ring.register_consumer().unwrap();
1317        let payload = [0xAAu8; 56];
1318        ring.try_send(0, &payload).unwrap();
1319        let mut out = [0u8; 64];
1320        let n = ring.try_recv(0, &mut out).unwrap();
1321        assert!(n >= 56);
1322        assert_eq!(&out[..56], &payload[..]);
1323    }
1324
1325    #[test]
1326    fn morph_grow_preserves_in_flight_items() {
1327        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1328        ring.register_producer().unwrap();
1329        ring.register_consumer().unwrap();
1330
1331        // Push 10 distinct items.
1332        for i in 0..10u64 {
1333            let mut payload = [0u8; 56];
1334            payload[..8].copy_from_slice(&i.to_le_bytes());
1335            ring.try_send(0, &payload).unwrap();
1336        }
1337
1338        // Grow to 256 slots.
1339        ring.morph_capacity_to(256).unwrap();
1340        assert_eq!(ring.current_capacity(), 256);
1341        assert_eq!(ring.pin_generation(), 1);
1342
1343        // Drain and verify every original item is present.
1344        let mut got = Vec::new();
1345        let mut out = [0u8; 64];
1346        while ring.try_recv(0, &mut out).is_ok() {
1347            let v = u64::from_le_bytes(out[..8].try_into().unwrap());
1348            got.push(v);
1349        }
1350        got.sort();
1351        assert_eq!(got, (0..10u64).collect::<Vec<_>>());
1352    }
1353
1354    #[test]
1355    fn morph_shrink_with_room_succeeds() {
1356        let ring = CapacityAdaptiveRing::create_anon(1, 1, 256).unwrap();
1357        ring.register_producer().unwrap();
1358        ring.register_consumer().unwrap();
1359
1360        // 5 items in a 256-slot ring.
1361        for i in 0..5u64 {
1362            let mut payload = [0u8; 56];
1363            payload[..8].copy_from_slice(&i.to_le_bytes());
1364            ring.try_send(0, &payload).unwrap();
1365        }
1366
1367        // Shrink to 64; 5 items fit easily.
1368        ring.morph_capacity_to(64).unwrap();
1369        assert_eq!(ring.current_capacity(), 64);
1370
1371        let mut got = Vec::new();
1372        let mut out = [0u8; 64];
1373        while ring.try_recv(0, &mut out).is_ok() {
1374            got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1375        }
1376        got.sort();
1377        assert_eq!(got, (0..5u64).collect::<Vec<_>>());
1378    }
1379
1380    #[test]
1381    fn morph_shrink_with_more_in_flight_than_new_capacity_succeeds() {
1382        // Under the stale-list design, shrinks always succeed:
1383        // in-flight items physically stay in the old (larger)
1384        // AdaptiveRing as part of the stale list and the
1385        // consumer drains them via try_recv's stale-walk. The
1386        // new capacity governs only items pushed AFTER the morph.
1387        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1388        ring.register_producer().unwrap();
1389        ring.register_consumer().unwrap();
1390
1391        // Fill to 40 items in the 64-slot ring.
1392        for i in 0..40u64 {
1393            let mut payload = [0u8; 56];
1394            payload[..8].copy_from_slice(&i.to_le_bytes());
1395            ring.try_send(0, &payload).unwrap();
1396        }
1397
1398        // Shrink to 16 slots. With the old in-flight items
1399        // sitting in the stale list, this succeeds without
1400        // touching them.
1401        ring.morph_capacity_to(16).expect("shrink succeeds via stale list");
1402        assert_eq!(ring.current_capacity(), 16);
1403        assert_eq!(ring.pin_generation(), 1);
1404
1405        // The consumer drains all 40 original items via the
1406        // stale-list walk in try_recv. Order is send-order
1407        // because the producer pushed sequentially into the
1408        // original SPSC ring; the consumer pops in the same
1409        // order via try_recv's stale-first dispatch.
1410        let mut got = Vec::new();
1411        let mut out = [0u8; 64];
1412        while ring.try_recv(0, &mut out).is_ok() {
1413            got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1414        }
1415        assert_eq!(got, (0..40u64).collect::<Vec<_>>(),
1416                   "all 40 original items drained via stale list in send-order");
1417    }
1418
1419    #[test]
1420    fn morph_to_same_capacity_is_noop() {
1421        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1422        let gen_before = ring.pin_generation();
1423        ring.morph_capacity_to(64).unwrap();
1424        assert_eq!(ring.pin_generation(), gen_before);
1425    }
1426
1427    #[test]
1428    fn morph_rejects_non_pow2_target() {
1429        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1430        let r = ring.morph_capacity_to(100);
1431        assert!(matches!(r, Err(CapacityMorphError::InvalidCapacity)));
1432    }
1433
1434    #[test]
1435    fn pin_invalidates_after_morph() {
1436        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1437        ring.register_producer().unwrap();
1438        ring.register_consumer().unwrap();
1439        let pin = ring.pin_current_capacity();
1440        assert!(pin.is_still_valid());
1441        assert_eq!(pin.capacity(), 64);
1442
1443        ring.morph_capacity_to(128).unwrap();
1444        assert!(!pin.is_still_valid());
1445
1446        let pin2 = ring.pin_current_capacity();
1447        assert!(pin2.is_still_valid());
1448        assert_eq!(pin2.capacity(), 128);
1449    }
1450
1451    #[test]
1452    fn multiple_grow_morphs_increment_generation_correctly() {
1453        let ring = CapacityAdaptiveRing::create_anon(1, 1, 4).unwrap();
1454        ring.register_producer().unwrap();
1455        ring.register_consumer().unwrap();
1456        assert_eq!(ring.pin_generation(), 0);
1457        ring.morph_capacity_to(8).unwrap();
1458        assert_eq!(ring.pin_generation(), 1);
1459        ring.morph_capacity_to(16).unwrap();
1460        assert_eq!(ring.pin_generation(), 2);
1461        ring.morph_capacity_to(64).unwrap();
1462        assert_eq!(ring.pin_generation(), 3);
1463        assert_eq!(ring.current_capacity(), 64);
1464    }
1465
1466    #[test]
1467    fn stamped_capacity_morph_preserves_ordering_axis() {
1468        let ring = CapacityAdaptiveRing::create_anon_stamped(2, 1, 64).unwrap();
1469        assert!(ring.is_stamped());
1470        ring.register_producer().unwrap();
1471        ring.register_consumer().unwrap();
1472        ring.set_ordering_mode(OrderingMode::MergeByStamp).unwrap();
1473
1474        // Items pushed pre-morph...
1475        for i in 0..6u64 {
1476            let mut payload = [0u8; 48];
1477            payload[..8].copy_from_slice(&i.to_le_bytes());
1478            ring.try_send(0, &payload).unwrap();
1479        }
1480        ring.morph_capacity_to(256).unwrap();
1481        assert_eq!(ring.ordering_mode(), Some(OrderingMode::MergeByStamp),
1482                   "the live mode flag must follow the capacity morph");
1483        // ...and post-morph items keep monotone stamps (the fresh
1484        // region is seeded from the old one).
1485        for i in 6..10u64 {
1486            let mut payload = [0u8; 48];
1487            payload[..8].copy_from_slice(&i.to_le_bytes());
1488            ring.try_send(0, &payload).unwrap();
1489        }
1490
1491        // Stale-first walk + per-backing merge = send order.
1492        let mut out = [0u8; 64];
1493        let mut got = Vec::new();
1494        while ring.try_recv(0, &mut out).is_ok() {
1495            got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1496        }
1497        assert_eq!(got, (0..10u64).collect::<Vec<_>>(),
1498                   "ordering must hold across the capacity morph boundary");
1499        assert_eq!(ring.inversions(), 0);
1500    }
1501
1502    #[test]
1503    fn cross_thread_concurrent_send_recv_through_morphs() {
1504        use std::thread;
1505
1506        let ring = Arc::new(CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap());
1507        ring.register_producer().unwrap();
1508        ring.register_consumer().unwrap();
1509
1510        let n = 5_000u64;
1511        let r_prod = Arc::clone(&ring);
1512        let prod = thread::spawn(move || {
1513            for i in 0..n {
1514                let mut payload = [0u8; 56];
1515                payload[..8].copy_from_slice(&i.to_le_bytes());
1516                while r_prod.try_send(0, &payload).is_err() {
1517                    std::hint::spin_loop();
1518                }
1519            }
1520        });
1521
1522        let r_cons = Arc::clone(&ring);
1523        let cons = thread::spawn(move || {
1524            let mut got = Vec::with_capacity(n as usize);
1525            let mut out = [0u8; 64];
1526            while got.len() < n as usize {
1527                if r_cons.try_recv(0, &mut out).is_ok() {
1528                    got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1529                }
1530            }
1531            got
1532        });
1533
1534        // Morph thread: grow 64 -> 128 -> 256 -> 128 -> 64 during the run.
1535        // Shrinks always succeed under the stale-list design (items in
1536        // flight stay in the prior backing and the consumer drains them
1537        // via try_recv's stale-walk), so this loop never retries.
1538        let r_morph = Arc::clone(&ring);
1539        let morph = thread::spawn(move || {
1540            let targets = [128usize, 256, 128, 64];
1541            for t in targets {
1542                std::thread::sleep(std::time::Duration::from_micros(500));
1543                r_morph.morph_capacity_to(t).expect("morph succeeds");
1544            }
1545        });
1546
1547        prod.join().unwrap();
1548        morph.join().unwrap();
1549        let mut got = cons.join().unwrap();
1550        got.sort();
1551        let expected: Vec<u64> = (0..n).collect();
1552        assert_eq!(got, expected);
1553    }
1554
1555    // ============================================================
1556    // Warm-backing pre-allocation
1557    // ============================================================
1558
1559    #[test]
1560    fn prewarm_hit_consumes_cache_and_morph_works() {
1561        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1562        ring.register_producer().unwrap();
1563        ring.register_consumer().unwrap();
1564
1565        ring.prewarm(256).unwrap();
1566        assert_eq!(ring.warm_capacity(), Some(256));
1567        assert_eq!(ring.warm_hits(), 0);
1568
1569        for i in 0..10u64 {
1570            let mut payload = [0u8; 56];
1571            payload[..8].copy_from_slice(&i.to_le_bytes());
1572            ring.try_send(0, &payload).unwrap();
1573        }
1574
1575        ring.morph_capacity_to(256).unwrap();
1576        assert_eq!(ring.warm_hits(), 1, "the morph must consume the prediction");
1577        assert_eq!(ring.warm_capacity(), None, "the slot is one-shot");
1578        assert_eq!(ring.current_capacity(), 256);
1579        assert_eq!(ring.pin_generation(), 1);
1580
1581        // Post-hit ring is fully functional: in-flight items drain
1582        // in send order and new pushes land on the warm backing.
1583        ring.try_send(0, &[0xBBu8; 56]).unwrap();
1584        let mut out = [0u8; 64];
1585        let mut got = Vec::new();
1586        while ring.try_recv(0, &mut out).is_ok() {
1587            got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1588        }
1589        assert_eq!(got.len(), 11);
1590        assert_eq!(&got[..10], &(0..10u64).collect::<Vec<_>>()[..]);
1591    }
1592
1593    #[test]
1594    fn prewarm_mismatch_stays_cached_and_cold_path_runs() {
1595        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1596        ring.register_producer().unwrap();
1597        ring.register_consumer().unwrap();
1598
1599        ring.prewarm(512).unwrap();
1600        ring.morph_capacity_to(256).unwrap();
1601        assert_eq!(ring.warm_hits(), 0, "mismatched prediction must not be consumed");
1602        assert_eq!(ring.warm_capacity(), Some(512), "mismatch stays cached");
1603        assert_eq!(ring.current_capacity(), 256);
1604
1605        ring.morph_capacity_to(512).unwrap();
1606        assert_eq!(ring.warm_hits(), 1, "the cached 512 serves the later morph");
1607        assert_eq!(ring.warm_capacity(), None);
1608    }
1609
1610    #[test]
1611    fn prewarm_rejects_non_pow2() {
1612        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1613        assert!(matches!(ring.prewarm(100), Err(CapacityMorphError::InvalidCapacity)));
1614        assert!(matches!(ring.prewarm(1), Err(CapacityMorphError::InvalidCapacity)));
1615        assert_eq!(ring.warm_capacity(), None);
1616    }
1617
1618    #[test]
1619    fn prewarm_same_capacity_is_idempotent_and_clear_drops() {
1620        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1621        ring.prewarm(128).unwrap();
1622        ring.prewarm(128).unwrap();
1623        assert_eq!(ring.warm_capacity(), Some(128));
1624        ring.prewarm(256).unwrap();
1625        assert_eq!(ring.warm_capacity(), Some(256), "new prediction replaces the old");
1626        ring.clear_warm();
1627        assert_eq!(ring.warm_capacity(), None);
1628    }
1629
1630    #[test]
1631    fn warm_morph_preserves_stamps_and_shape() {
1632        let ring = CapacityAdaptiveRing::create_anon_stamped(2, 1, 64).unwrap();
1633        ring.register_producer().unwrap();
1634        ring.register_consumer().unwrap();
1635        ring.set_ordering_mode(OrderingMode::MergeByStamp).unwrap();
1636        ring.ring_handle()
1637            .morph_to(crate::adaptive_ring::RingShape::Mpsc)
1638            .unwrap();
1639
1640        for i in 0..6u64 {
1641            let mut payload = [0u8; 48];
1642            payload[..8].copy_from_slice(&i.to_le_bytes());
1643            ring.try_send(0, &payload).unwrap();
1644        }
1645
1646        ring.prewarm(256).unwrap();
1647        ring.morph_capacity_to(256).unwrap();
1648        assert_eq!(ring.warm_hits(), 1);
1649        assert_eq!(ring.ordering_mode(), Some(OrderingMode::MergeByStamp),
1650                   "live mode flag must follow a warm-hit morph");
1651        assert_eq!(ring.ring_handle().current_shape(),
1652                   crate::adaptive_ring::RingShape::Mpsc,
1653                   "shape must be mirrored onto the warm backing");
1654
1655        for i in 6..10u64 {
1656            let mut payload = [0u8; 48];
1657            payload[..8].copy_from_slice(&i.to_le_bytes());
1658            ring.try_send(0, &payload).unwrap();
1659        }
1660        let mut out = [0u8; 64];
1661        let mut got = Vec::new();
1662        while ring.try_recv(0, &mut out).is_ok() {
1663            got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1664        }
1665        assert_eq!(got, (0..10u64).collect::<Vec<_>>(),
1666                   "send order must hold across a warm-hit morph (seeded stamps)");
1667        assert_eq!(ring.inversions(), 0);
1668    }
1669
1670    #[test]
1671    fn warm_morph_file_locale_round_trips() {
1672        let dir = std::env::temp_dir().join(format!(
1673            "subetha_warm_file_{}", std::process::id(),
1674        ));
1675        std::fs::create_dir_all(&dir).unwrap();
1676        let base = dir.join("warm_probe");
1677        {
1678            let ring = CapacityAdaptiveRing::create(&base, 1, 1, 64).unwrap();
1679            ring.register_producer().unwrap();
1680            ring.register_consumer().unwrap();
1681            for i in 0..5u64 {
1682                let mut payload = [0u8; 56];
1683                payload[..8].copy_from_slice(&i.to_le_bytes());
1684                ring.try_send(0, &payload).unwrap();
1685            }
1686            ring.prewarm(128).unwrap();
1687            ring.morph_capacity_to(128).unwrap();
1688            assert_eq!(ring.warm_hits(), 1);
1689            // Cycle back down through a second prewarm at a
1690            // previously-used capacity - the per-build sequence
1691            // number keeps the file names unique.
1692            ring.prewarm(64).unwrap();
1693            ring.morph_capacity_to(64).unwrap();
1694            assert_eq!(ring.warm_hits(), 2);
1695            let mut out = [0u8; 64];
1696            let mut got = Vec::new();
1697            while ring.try_recv(0, &mut out).is_ok() {
1698                got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1699            }
1700            assert_eq!(got, (0..5u64).collect::<Vec<_>>());
1701        }
1702        drop(std::fs::remove_dir_all(&dir));
1703    }
1704
1705    #[test]
1706    fn default_policy_predict_bands() {
1707        let policy = DefaultCapacityPolicy::default(); // grow 0.85 / shrink 0.10
1708        let obs = |len: usize, cap: usize| CapacityPolicyObservation {
1709            current_capacity: cap,
1710            active_approx_len: len,
1711            total_slot_capacity: cap,
1712            since_last_morph: std::time::Duration::ZERO,
1713        };
1714        // 0.70 fill >= 0.6375 trend band -> predict double.
1715        assert_eq!(policy.predict(&obs(716, 1024)), Some(2048));
1716        // 0.50 fill sits between the bands -> no prediction.
1717        assert_eq!(policy.predict(&obs(512, 1024)), None);
1718        // 0.14 fill <= 0.15 trend band -> predict half.
1719        assert_eq!(policy.predict(&obs(143, 1024)), Some(512));
1720        // Caps respected at the ladder ends.
1721        assert_eq!(policy.predict(&obs(60000, 65536)), None);
1722        assert_eq!(policy.predict(&obs(0, 64)), None);
1723        // predict ignores hysteresis (decide does not).
1724        let fresh = CapacityPolicyObservation {
1725            since_last_morph: std::time::Duration::ZERO,
1726            ..obs(716, 1024)
1727        };
1728        assert_eq!(policy.decide(&fresh), None, "decide is hysteresis-gated");
1729        assert_eq!(policy.predict(&fresh), Some(2048), "predict is not");
1730    }
1731
1732    #[test]
1733    fn policy_without_predict_override_never_prewarms() {
1734        struct GrowOnly;
1735        impl CapacityPolicy for GrowOnly {
1736            fn decide(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1737                (obs.fill_ratio() >= 0.85).then_some(obs.current_capacity * 2)
1738            }
1739        }
1740        let ring = Arc::new(CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap());
1741        ring.register_producer().unwrap();
1742        ring.register_consumer().unwrap();
1743        let sidecar = CapacityAdaptiveRingSidecar::spawn(
1744            Arc::clone(&ring), GrowOnly, std::time::Duration::from_millis(2),
1745        );
1746        // Hold fill high enough that a predicting policy is sure
1747        // to act; the trait-default one must not.
1748        for _ in 0..50 {
1749            ring.try_send(0, &[0u8; 56]).ok();
1750        }
1751        std::thread::sleep(std::time::Duration::from_millis(50));
1752        assert_eq!(sidecar.prewarms_issued(), 0,
1753                   "trait-default predict() must keep today's behavior");
1754        sidecar.shutdown();
1755    }
1756
1757    #[test]
1758    fn sidecar_prewarms_on_sustained_trend_then_morph_hits_warm() {
1759        // Deterministic test policy: predict fires in a band BELOW
1760        // the decide threshold, so the test controls each stage by
1761        // fill level alone.
1762        struct Banded;
1763        impl CapacityPolicy for Banded {
1764            fn decide(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1765                (obs.fill_ratio() >= 0.85).then_some(obs.current_capacity * 2)
1766            }
1767            fn predict(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1768                (obs.fill_ratio() >= 0.60).then_some(obs.current_capacity * 2)
1769            }
1770        }
1771        let ring = Arc::new(CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap());
1772        ring.register_producer().unwrap();
1773        ring.register_consumer().unwrap();
1774        let sidecar = CapacityAdaptiveRingSidecar::spawn(
1775            Arc::clone(&ring), Banded, std::time::Duration::from_millis(2),
1776        );
1777
1778        // Stage 1: fill into the predict band (45/64 = 0.70) and
1779        // wait for the sustained-trend prewarm.
1780        for _ in 0..45 {
1781            ring.try_send(0, &[0u8; 56]).unwrap();
1782        }
1783        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1784        while ring.warm_capacity() != Some(128) {
1785            assert!(std::time::Instant::now() < deadline,
1786                    "sidecar must prewarm 128 from the sustained trend");
1787            std::thread::sleep(std::time::Duration::from_millis(2));
1788        }
1789        assert!(sidecar.prewarms_issued() >= 1);
1790
1791        // Stage 2: push over the decide threshold (56/64 = 0.875)
1792        // and wait for the morph to consume the warm backing.
1793        for _ in 0..11 {
1794            ring.try_send(0, &[0u8; 56]).unwrap();
1795        }
1796        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1797        while ring.current_capacity() != 128 {
1798            assert!(std::time::Instant::now() < deadline,
1799                    "sidecar must morph to 128 once fill crosses decide");
1800            std::thread::sleep(std::time::Duration::from_millis(2));
1801        }
1802        assert_eq!(ring.warm_hits(), 1,
1803                   "the sidecar-driven morph must consume the prewarmed backing");
1804        sidecar.shutdown();
1805
1806        // Integrity: every pushed item drains.
1807        let mut out = [0u8; 64];
1808        let mut n = 0;
1809        while ring.try_recv(0, &mut out).is_ok() {
1810            n += 1;
1811        }
1812        assert_eq!(n, 56);
1813    }
1814
1815    // ============================================================
1816    // Compound multi-axis morphs
1817    // ============================================================
1818
1819    #[test]
1820    fn compound_capacity_plus_shape_is_one_generation() {
1821        let ring = CapacityAdaptiveRing::create_anon(4, 1, 64).unwrap();
1822        ring.register_producer().unwrap();
1823        ring.register_consumer().unwrap();
1824        for i in 0..10u64 {
1825            let mut p = [0u8; 56];
1826            p[..8].copy_from_slice(&i.to_le_bytes());
1827            ring.try_send(0, &p).unwrap();
1828        }
1829
1830        ring.morph_to_config(&RingConfig {
1831            shape: Some(RingShape::Mpmc),
1832            capacity: Some(512),
1833            locale: None,
1834        })
1835        .unwrap();
1836        assert_eq!(ring.pin_generation(), 1,
1837                   "two axes, ONE pin invalidation");
1838        assert_eq!(ring.current_capacity(), 512);
1839        assert_eq!(ring.ring_handle().current_shape(), RingShape::Mpmc);
1840
1841        // Three more producers join post-morph and everything
1842        // drains exactly once.
1843        for _ in 0..3 {
1844            ring.register_producer().unwrap();
1845        }
1846        for pid in 1..4usize {
1847            let mut p = [0u8; 56];
1848            p[..8].copy_from_slice(&(100 + pid as u64).to_le_bytes());
1849            ring.try_send(pid, &p).unwrap();
1850        }
1851        let mut out = [0u8; 64];
1852        let mut got = Vec::new();
1853        while ring.try_recv(0, &mut out).is_ok() {
1854            got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1855        }
1856        got.sort();
1857        let mut expected: Vec<u64> = (0..10).collect();
1858        expected.extend([101, 102, 103]);
1859        assert_eq!(got, expected);
1860    }
1861
1862    #[test]
1863    fn shape_only_config_morphs_in_place_without_pin_bump() {
1864        let ring = CapacityAdaptiveRing::create_anon(4, 1, 64).unwrap();
1865        ring.register_producer().unwrap();
1866        ring.register_consumer().unwrap();
1867        ring.try_send(0, &[0x11u8; 56]).unwrap();
1868
1869        let active_before = ring.ring_handle();
1870        ring.morph_to_config(&RingConfig {
1871            shape: Some(RingShape::Mpsc),
1872            ..RingConfig::default()
1873        })
1874        .unwrap();
1875        assert_eq!(ring.pin_generation(), 0,
1876                   "in-place shape morph must not invalidate the capacity pin");
1877        assert!(Arc::ptr_eq(&active_before, &ring.ring_handle()),
1878                "active backing must be the same instance");
1879        assert_eq!(ring.ring_handle().current_shape(), RingShape::Mpsc);
1880        let mut out = [0u8; 64];
1881        assert!(ring.try_recv(0, &mut out).is_ok(),
1882                "in-flight item survives the in-place shape morph");
1883    }
1884
1885    #[test]
1886    fn config_noop_when_every_axis_matches() {
1887        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1888        ring.morph_to_config(&RingConfig::default()).unwrap();
1889        ring.morph_to_config(&RingConfig {
1890            shape: Some(RingShape::Spsc),
1891            capacity: Some(64),
1892            locale: Some(BackingTarget::Anon),
1893        })
1894        .unwrap();
1895        assert_eq!(ring.pin_generation(), 0);
1896    }
1897
1898    #[test]
1899    fn compound_locale_change_drains_across_locales() {
1900        let dir = std::env::temp_dir().join(format!(
1901            "subetha_compound_locale_{}", std::process::id(),
1902        ));
1903        std::fs::create_dir_all(&dir).unwrap();
1904        let base = dir.join("compound");
1905        {
1906            let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1907            ring.register_producer().unwrap();
1908            ring.register_consumer().unwrap();
1909            for i in 0..8u64 {
1910                let mut p = [0u8; 56];
1911                p[..8].copy_from_slice(&i.to_le_bytes());
1912                ring.try_send(0, &p).unwrap();
1913            }
1914
1915            // Capacity + locale in one transition: anon -> file.
1916            ring.morph_to_config(&RingConfig {
1917                shape: None,
1918                capacity: Some(256),
1919                locale: Some(BackingTarget::File(base.clone())),
1920            })
1921            .unwrap();
1922            assert_eq!(ring.pin_generation(), 1);
1923            assert_eq!(ring.current_capacity(), 256);
1924
1925            // Items pushed pre-morph (anon) and post-morph (file)
1926            // drain in send order across the locale boundary.
1927            ring.try_send(0, &{
1928                let mut p = [0u8; 56];
1929                p[..8].copy_from_slice(&99u64.to_le_bytes());
1930                p
1931            }).unwrap();
1932            let mut out = [0u8; 64];
1933            let mut got = Vec::new();
1934            while ring.try_recv(0, &mut out).is_ok() {
1935                got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1936            }
1937            let mut expected: Vec<u64> = (0..8).collect();
1938            expected.push(99);
1939            assert_eq!(got, expected);
1940
1941            // Subsequent morphs allocate at the retargeted locale.
1942            ring.morph_capacity_to(512).unwrap();
1943            let file_backings: Vec<_> = std::fs::read_dir(&dir).unwrap()
1944                .filter_map(|e| e.ok())
1945                .filter(|e| e.file_name().to_string_lossy().contains("cap_512"))
1946                .collect();
1947            assert!(!file_backings.is_empty(),
1948                    "post-retarget morphs must allocate file backings");
1949        }
1950        drop(std::fs::remove_dir_all(&dir));
1951    }
1952
1953    #[test]
1954    fn repatch_prewarm_config_full_target_hits() {
1955        let ring = CapacityAdaptiveRing::create_anon(4, 1, 64).unwrap();
1956        ring.register_producer().unwrap();
1957        ring.register_consumer().unwrap();
1958
1959        let target = RingConfig {
1960            shape: Some(RingShape::Mpmc),
1961            capacity: Some(1024),
1962            locale: None,
1963        };
1964        ring.prewarm_config(&target).unwrap();
1965        assert_eq!(ring.warm_capacity(), Some(1024));
1966        ring.morph_to_config(&target).unwrap();
1967        assert_eq!(ring.warm_hits(), 1,
1968                   "repatch must consume the full-target prediction");
1969        assert_eq!(ring.ring_handle().current_shape(), RingShape::Mpmc);
1970        assert_eq!(ring.current_capacity(), 1024);
1971    }
1972
1973    #[test]
1974    fn warm_key_locale_mismatch_is_cold() {
1975        let dir = std::env::temp_dir().join(format!(
1976            "subetha_warm_locale_key_{}", std::process::id(),
1977        ));
1978        std::fs::create_dir_all(&dir).unwrap();
1979        {
1980            let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1981            // Prewarm at the CURRENT (anon) locale...
1982            ring.prewarm(256).unwrap();
1983            // ...then morph to the same capacity at a DIFFERENT
1984            // locale: the key mismatch must force the cold path.
1985            ring.morph_to_config(&RingConfig {
1986                shape: None,
1987                capacity: Some(256),
1988                locale: Some(BackingTarget::File(dir.join("keyed"))),
1989            })
1990            .unwrap();
1991            assert_eq!(ring.warm_hits(), 0,
1992                       "an anon-built backing must never serve a file-locale morph");
1993            assert_eq!(ring.warm_capacity(), Some(256),
1994                       "the mismatched prediction stays cached");
1995            ring.clear_warm();
1996        }
1997        drop(std::fs::remove_dir_all(&dir));
1998    }
1999
2000    #[test]
2001    fn stale_pops_counts_transition_items() {
2002        let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
2003        ring.register_producer().unwrap();
2004        ring.register_consumer().unwrap();
2005        for i in 0..7u64 {
2006            let mut p = [0u8; 56];
2007            p[..8].copy_from_slice(&i.to_le_bytes());
2008            ring.try_send(0, &p).unwrap();
2009        }
2010        ring.morph_capacity_to(256).unwrap();
2011        ring.try_send(0, &[0x22u8; 56]).unwrap();
2012        let mut out = [0u8; 64];
2013        while ring.try_recv(0, &mut out).is_ok() {}
2014        assert_eq!(ring.stale_pops(), 7,
2015                   "exactly the pre-morph items traverse the stale walk");
2016    }
2017}