Skip to main content

net/adapter/net/behavior/sensing/
emitter.rs

1//! SI-3 origin emitter (plan §4.4): the provider-side scheduler for
2//! signed readiness streams. Hardened by the SI-3 review closure
3//! packet (§6 review disposition): live-stream capacity, bounded
4//! duration arithmetic, a two-phase due/finalize API that keeps
5//! user evaluators OUTSIDE the emitter lock, and stamped retirement
6//! that closes the register/retire race.
7//!
8//! The origin compiles each distinct interest ONCE (the validated
9//! constraints and envelope are cloned into the stream slot at
10//! registration; a refresh of the same digest never re-parses),
11//! evaluates against its CURRENT generation at every beat, and emits
12//! one signed stream per distinct interest at
13//! `promised_cadence = max(strictest-D / 2, attestation_cadence_floor)`
14//! — status edges are pulled forward to "now" with the floor as the
15//! min-gap, and an interest whose last downstream died emits nothing
16//! at all (zero idle emission, plan §4.7).
17//!
18//! This state machine is deliberately **pure and crypto-free**: it
19//! produces [`UnsignedAttestation`]s and the mesh layer signs them
20//! (`sign_attestation`) where the keypair lives, so every scheduling
21//! rule here is fake-clock testable without keys. A burned sequence
22//! number on a (never-observed) signing failure is harmless: seq
23//! gaps carry no meaning beyond strictly-newer admission (§4.4).
24//!
25//! # Two-phase emission (closure item 5)
26//!
27//! `ReadinessEvaluator::evaluate` is arbitrary user code and may
28//! legitimately call back into `MeshNode` (a notify hook, stream
29//! introspection) — running it under the emitter mutex would
30//! deadlock a non-reentrant lock. The API is therefore split:
31//!
32//! 1. **Under the lock**: [`OriginEmitter::collect_due`] reserves
33//!    the sequence number, re-arms the schedule, and snapshots the
34//!    compiled predicate into a [`DueBeat`] (the predicate rides an
35//!    `Arc`, so the snapshot is cheap).
36//! 2. **Without the lock**: the caller runs the evaluator against
37//!    [`DueBeat::request`].
38//! 3. No re-lock is needed to finalize: everything the §4.2
39//!    transcript binds was reserved in step 1, so
40//!    [`DueBeat::into_unsigned`] is a pure function. A beat whose
41//!    stream was retired between the phases is harmless — its
42//!    downstream set reads empty and the caller drops it.
43//!
44//! # Capacity (closure item 1)
45//!
46//! Live streams are capped at [`MAX_LIVE_SENSING_STREAMS`] (1024) —
47//! sized so worst-case signing at the 50 ms floor stays well under
48//! one core (SI-1d: ~13.3 µs/sign → ~27% of a core at cap). At
49//! capacity: refreshes of live digests are accepted, new or
50//! resurrected digests are refused ([`StreamRefusal::AtCapacity`]),
51//! no live stream is ever evicted, and a capacity refusal mints NO
52//! sequence slot. The 8192-slot LRU below is SEQUENCE-memory
53//! capacity, not live capacity.
54//!
55//! # Sequence memory outlives the stream
56//!
57//! Seqs are per `(origin, origin_incarnation, interest_digest)`
58//! (§4.6) and MUST NOT restart when a stream dies and re-forms
59//! within one incarnation — a reset would replay `(incarnation,
60//! seq)` pairs with different payloads, which downstream observer
61//! gates rightly treat as equivocation and poison. Retired streams
62//! therefore leave their seq counter behind in the slot map. The map
63//! is bounded exactly like the observer gate it feeds
64//! (`IncarnationSeqGate`, SI-1c): oldest-touched RETIRED slots evict
65//! first past [`MAX_STREAM_SLOTS`], and LIVE slots are never evicted
66//! — evicting a live stream's counter would be self-inflicted
67//! equivocation. A re-registration after its retired slot was
68//! evicted restarts at seq 0 and is contained at the observer gate
69//! as an ordinary rollback (stale drops, never a flap).
70//!
71//! # Stamped retirement (closure item 7)
72//!
73//! Retirement decisions are made from TABLE state read outside this
74//! lock, so a registration can land between that read and the
75//! retire call and be killed despite holding a live row (dark until
76//! the next refresh). Every successful [`OriginEmitter::register`]
77//! stamps the stream from the emitter's monotonic counter; callers
78//! snapshot [`OriginEmitter::stamp`] BEFORE reading the table and
79//! retire with [`OriginEmitter::retire_if_stale`], which refuses to
80//! kill a stream registered after the snapshot.
81//!
82//! # Cadence refusal (one-shot, rides the attestation plane)
83//!
84//! A coalesced strictest D below the floor is refused
85//! ([`check_cadence`]) — the caller partitions its downstreams
86//! (`InterestTable::on_refusal`) and sends the refused ones ONE
87//! signed refusal beat ([`OriginEmitter::refusal_beat`]): status
88//! `ProviderUnknown` / `SamplingIntervalUnsupported`, with the
89//! provider floor M carried in `promised_cadence` — a TAGGED
90//! interpretation (SI-3 review): under that `status_reason`, the
91//! signed field means `minimum_supported`. Refusal beats draw from
92//! the same seq slot as the stream, so the two can never collide on
93//! `(incarnation, seq)`; a slot is minted only when a response is
94//! actually authored (a capacity refusal sends nothing and mints
95//! nothing).
96//!
97//! # Bounded time arithmetic (closure item 4)
98//!
99//! The mesh bounds wire intervals at intake
100//! (`0 < D ≤ sensing_interest_ttl`), but this module never trusts
101//! that: all scheduling uses `schedule_after` (checked add,
102//! far-future park on overflow) and a zero cadence floor is
103//! normalized to the default at construction, so no cadence value —
104//! however malformed — can panic or hot-loop the emitter task.
105
106use std::collections::HashMap;
107use std::sync::Arc;
108use std::time::{Duration, Instant};
109
110use super::evaluator::{
111    check_cadence, project_evaluation, CadenceRefusal, EvaluationRequest, ReadinessEvaluation,
112    DEFAULT_ATTESTATION_CADENCE_FLOOR,
113};
114use super::identity::{
115    AudienceScopeCommitment, CanonicalConstraints, CapabilityId, CapabilityInterestKey, Digest256,
116    InterestSpec, WorkLatencyEnvelope,
117};
118use super::incarnation::Incarnation;
119use super::wire::UnsignedAttestation;
120
121/// Hard cap on concurrently LIVE emission streams (closure item 1).
122/// Sized from the SI-1d benchmark: 1024 streams all at the 50 ms
123/// floor cost ~27% of one core in signatures — the origin role stays
124/// a background workload under worst-case demand. Honest working
125/// sets are far smaller (identical specs coalesce into one digest);
126/// a node near this cap almost certainly has a consumer defeating
127/// coalescing, and refusing surfaces that.
128pub const MAX_LIVE_SENSING_STREAMS: usize = 1024;
129
130/// Bound on the seq-slot map (live + retired), mirroring the
131/// observer gate's bound (SI-1c): comfortably above any honest
132/// interest population, small enough that a hostile registration
133/// storm cannot grow memory unboundedly.
134pub const MAX_STREAM_SLOTS: usize = 8192;
135
136/// Eviction low-water mark: one sweep past [`MAX_STREAM_SLOTS`]
137/// frees a batch of retired slots so steady-state churn does not
138/// re-trigger the sweep on every insert.
139pub const STREAM_SLOTS_LOW_WATER: usize = 6144;
140
141/// Overflow park (closure item 4): a schedule whose checked add
142/// overflows re-arms this far out instead — effectively "never",
143/// without panicking and without an immediately-due hot loop.
144const OVERFLOW_PARK: Duration = Duration::from_secs(60 * 60 * 24 * 365);
145
146/// `base + delta` that can neither panic nor hot-loop: overflow
147/// parks the schedule [`OVERFLOW_PARK`] out (and in the
148/// never-observed case where even that overflows, falls back to
149/// `base` — a bounded extra beat, not a crash).
150fn schedule_after(base: Instant, delta: Duration) -> Instant {
151    base.checked_add(delta)
152        .or_else(|| base.checked_add(OVERFLOW_PARK))
153        .unwrap_or(base)
154}
155
156/// Why [`OriginEmitter::register`] refused a stream (closure
157/// item 1 split the refusal space).
158#[derive(Clone, Copy, PartialEq, Eq, Debug)]
159pub enum StreamRefusal {
160    /// The coalesced strictest D is below the provider floor —
161    /// answer with [`OriginEmitter::refusal_beat`] after
162    /// partitioning (§4.4).
163    Cadence(CadenceRefusal),
164    /// [`MAX_LIVE_SENSING_STREAMS`] live streams already exist and
165    /// this digest is not one of them. No slot was minted, nothing
166    /// was evicted; the refusal is local (log/observe) — there is no
167    /// §4.4 wire response for capacity.
168    AtCapacity,
169}
170
171/// The compiled evaluation inputs of one interest — parsed once at
172/// registration, shared into each [`DueBeat`] by `Arc` so the
173/// two-phase split never re-clones the constraint map per beat.
174#[derive(Debug)]
175struct CompiledPredicate {
176    capability_id: CapabilityId,
177    constraints: CanonicalConstraints,
178    work_latency: WorkLatencyEnvelope,
179}
180
181/// One interest's live schedule.
182#[derive(Debug)]
183struct LiveStream {
184    /// Table/index identity of the stream (digest + capability id).
185    key: CapabilityInterestKey,
186    /// Compiled predicate (module docs — compile once per digest).
187    predicate: Arc<CompiledPredicate>,
188    /// The audience the interest was validated under — signed into
189    /// every beat so a proof can never be re-homed (§4.2).
190    audience: AudienceScopeCommitment,
191    /// `max(strictest-D / 2, floor)` — recomputed whenever the
192    /// caller re-registers with a moved aggregate.
193    promised_cadence: Duration,
194    /// Next scheduled emission.
195    due_at: Instant,
196    /// Last emission instant — the edge min-gap reference.
197    last_emitted_at: Option<Instant>,
198    /// Monotonic stamp of the most recent (re-)registration —
199    /// [`OriginEmitter::retire_if_stale`] refuses to kill a stream
200    /// registered after the caller's snapshot (closure item 7).
201    registered_stamp: u64,
202}
203
204/// One digest's slot: the seq counter (which outlives the stream —
205/// module docs) plus the live stream state, if any.
206#[derive(Debug)]
207struct StreamSlot {
208    /// Next sequence number to sign for this digest.
209    next_seq: u64,
210    /// LRU touch stamp (monotonic per emitter, not a clock).
211    touched: u64,
212    /// `Some` while at least one downstream is interested.
213    live: Option<LiveStream>,
214}
215
216/// One reserved-but-unevaluated beat (two-phase emission, module
217/// docs): everything the §4.2 transcript binds except the
218/// evaluation outcome, snapshotted under the emitter lock. Run the
219/// evaluator against [`Self::request`] WITHOUT the lock, then seal
220/// with [`Self::into_unsigned`] — a pure function.
221#[derive(Debug)]
222pub struct DueBeat {
223    key: CapabilityInterestKey,
224    predicate: Arc<CompiledPredicate>,
225    audience: AudienceScopeCommitment,
226    origin: u64,
227    incarnation: Incarnation,
228    generation: u64,
229    seq: u64,
230    promised_cadence: Duration,
231    /// The collect-phase stamp — pass to
232    /// [`OriginEmitter::retire_if_stale`] when this beat's
233    /// downstream set reads empty.
234    stamp: u64,
235}
236
237impl DueBeat {
238    /// The stream identity this beat answers.
239    pub fn key(&self) -> &CapabilityInterestKey {
240        &self.key
241    }
242
243    /// The collect-phase stamp (module docs, stamped retirement).
244    pub fn stamp(&self) -> u64 {
245        self.stamp
246    }
247
248    /// The evaluation inputs — run the integration against this
249    /// OUTSIDE the emitter lock.
250    pub fn request(&self) -> EvaluationRequest<'_> {
251        EvaluationRequest {
252            capability_id: &self.predicate.capability_id,
253            constraints: &self.predicate.constraints,
254            work_latency: &self.predicate.work_latency,
255        }
256    }
257
258    /// Seal the beat with the evaluation outcome. `None` (no
259    /// evaluator registered for the capability) projects as
260    /// `ProviderUnknown { TemporarilyUnevaluable }` — an explicit
261    /// "targeted but cannot answer" stream beats silence.
262    pub fn into_unsigned(self, evaluation: Option<ReadinessEvaluation>) -> UnsignedAttestation {
263        let evaluation = evaluation.unwrap_or(ReadinessEvaluation::TemporarilyUnevaluable);
264        let (status, status_reason) = project_evaluation(&evaluation);
265        let estimated_start = match evaluation {
266            ReadinessEvaluation::Ready { estimated_start } => estimated_start,
267            _ => None,
268        };
269        UnsignedAttestation {
270            interest_digest: self.key.interest_digest,
271            origin: self.origin,
272            origin_incarnation: self.incarnation,
273            capability_id: self.key.capability_id,
274            capability_generation: self.generation,
275            status,
276            status_reason,
277            estimated_start,
278            seq: self.seq,
279            promised_cadence: self.promised_cadence,
280            audience_scope: self.audience,
281        }
282    }
283}
284
285/// The origin's emission scheduler (module docs). One per node;
286/// single-writer by construction (the mesh serializes access), which
287/// is what makes "never two payloads on one `(incarnation, seq)`"
288/// structural.
289#[derive(Debug)]
290pub struct OriginEmitter {
291    /// This node's id — the attestation `origin`.
292    origin: u64,
293    /// The §4.6 boot epoch every beat is scoped to (caller derives
294    /// it via `next_incarnation` over real persistence,
295    /// increment-before-participation — TRUSTED caller input, per
296    /// the accepted deviation).
297    incarnation: Incarnation,
298    /// `attestation_cadence_floor` (plan §5): cadence lower bound
299    /// AND the status-edge min-gap. Normalized non-zero at
300    /// construction (closure item 4).
301    cadence_floor: Duration,
302    /// Seq slots by interest digest — live streams + retired seq
303    /// memory, LRU-bounded (module docs).
304    slots: HashMap<Digest256, StreamSlot>,
305    /// Live-stream count, maintained on every live transition so
306    /// the capacity check is O(1) (closure item 1).
307    live_count: usize,
308    /// Monotonic LRU/registration stamp source.
309    touch_counter: u64,
310}
311
312impl OriginEmitter {
313    /// New emitter for one `(origin, incarnation)` scope. A zero
314    /// `cadence_floor` is normalized to
315    /// [`DEFAULT_ATTESTATION_CADENCE_FLOOR`] — a zero floor would
316    /// admit a zero cadence and hot-loop the emitter task (closure
317    /// item 4).
318    pub fn new(origin: u64, incarnation: Incarnation, cadence_floor: Duration) -> Self {
319        let cadence_floor = if cadence_floor.is_zero() {
320            DEFAULT_ATTESTATION_CADENCE_FLOOR
321        } else {
322            cadence_floor
323        };
324        Self {
325            origin,
326            incarnation,
327            cadence_floor,
328            slots: HashMap::new(),
329            live_count: 0,
330            touch_counter: 0,
331        }
332    }
333
334    fn touch(&mut self) -> u64 {
335        self.touch_counter += 1;
336        self.touch_counter
337    }
338
339    /// The current registration stamp. Snapshot this BEFORE reading
340    /// table state that a retirement decision will rest on, then
341    /// retire with [`Self::retire_if_stale`] (closure item 7).
342    pub fn stamp(&self) -> u64 {
343        self.touch_counter
344    }
345
346    /// Register (or refresh) one interest stream at the caller's
347    /// current strictest aggregate D.
348    ///
349    /// - A strictest D below the floor is refused
350    ///   ([`StreamRefusal::Cadence`]) — stream state untouched; the
351    ///   caller partitions and answers with [`Self::refusal_beat`].
352    /// - At [`MAX_LIVE_SENSING_STREAMS`], a digest that is not
353    ///   already live is refused ([`StreamRefusal::AtCapacity`])
354    ///   without minting a slot; live refreshes always succeed.
355    /// - First registration schedules the first beat at `now`; a
356    ///   refresh only moves the schedule when the CADENCE moved (a
357    ///   ttl/2 refresh must never starve the cadence by pushing
358    ///   `due_at` forever forward).
359    pub fn register(
360        &mut self,
361        spec: &InterestSpec,
362        strictest: Duration,
363        now: Instant,
364    ) -> Result<(), StreamRefusal> {
365        check_cadence(strictest, self.cadence_floor).map_err(StreamRefusal::Cadence)?;
366        let promised_cadence = (strictest / 2).max(self.cadence_floor);
367        let key = CapabilityInterestKey::for_spec(spec);
368        let digest = key.interest_digest;
369        let already_live = self
370            .slots
371            .get(&digest)
372            .is_some_and(|slot| slot.live.is_some());
373        if !already_live && self.live_count >= MAX_LIVE_SENSING_STREAMS {
374            return Err(StreamRefusal::AtCapacity);
375        }
376        let stamp = self.touch();
377        let slot = self.slots.entry(digest).or_insert(StreamSlot {
378            next_seq: 0,
379            touched: 0,
380            live: None,
381        });
382        slot.touched = stamp;
383        match &mut slot.live {
384            Some(stream) => {
385                stream.registered_stamp = stamp;
386                if stream.promised_cadence != promised_cadence {
387                    stream.promised_cadence = promised_cadence;
388                    // Re-derive the schedule from the last beat under
389                    // the new cadence — a tightened aggregate pulls
390                    // the next beat earlier, a loosened one pushes it
391                    // out; either way the min-gap (floor) holds.
392                    stream.due_at = match stream.last_emitted_at {
393                        Some(last) => schedule_after(last, promised_cadence).max(now),
394                        None => now,
395                    };
396                }
397            }
398            None => {
399                slot.live = Some(LiveStream {
400                    key,
401                    predicate: Arc::new(CompiledPredicate {
402                        capability_id: spec.capability_id.clone(),
403                        constraints: spec.constraints.clone(),
404                        work_latency: spec.work_latency,
405                    }),
406                    audience: spec.audience,
407                    promised_cadence,
408                    due_at: now,
409                    last_emitted_at: None,
410                    registered_stamp: stamp,
411                });
412                self.live_count += 1;
413            }
414        }
415        self.evict_retired();
416        Ok(())
417    }
418
419    /// The stream's last downstream died (deregister, ttl sweep, or
420    /// refusal partition) — stop emitting, keep the seq memory
421    /// (module docs). Idempotent. Prefer [`Self::retire_if_stale`]
422    /// whenever the decision rests on table state read outside the
423    /// emitter lock.
424    pub fn retire(&mut self, digest: &Digest256) {
425        if let Some(slot) = self.slots.get_mut(digest) {
426            if slot.live.take().is_some() {
427                self.live_count -= 1;
428            }
429        }
430    }
431
432    /// Retire ONLY if the stream has not been (re-)registered since
433    /// the caller's [`Self::stamp`] snapshot — the register/retire
434    /// race closure (module docs, item 7). Returns whether the
435    /// stream was retired.
436    pub fn retire_if_stale(&mut self, digest: &Digest256, seen_stamp: u64) -> bool {
437        let Some(slot) = self.slots.get_mut(digest) else {
438            return false;
439        };
440        let Some(stream) = &slot.live else {
441            return false;
442        };
443        if stream.registered_stamp > seen_stamp {
444            // A registration landed after the caller observed the
445            // table — the emptiness it acted on is stale.
446            return false;
447        }
448        slot.live = None;
449        self.live_count -= 1;
450        true
451    }
452
453    /// A local state change on `capability_id` (the integration's
454    /// notify hook): pull every live stream on that capability
455    /// forward to "now", min-gapped at the floor since its last
456    /// beat. Returns whether any schedule moved (the caller only
457    /// needs to wake its loop if one did).
458    ///
459    /// Deliberately re-emits even if the re-evaluation lands on the
460    /// same status: at most one early beat per poke, absorbed
461    /// downstream by strictly-newer admission — cheaper and simpler
462    /// than caching cross-beat comparisons here.
463    pub fn poke(&mut self, capability_id: &CapabilityId, now: Instant) -> bool {
464        let floor = self.cadence_floor;
465        let mut moved = false;
466        for slot in self.slots.values_mut() {
467            let Some(stream) = &mut slot.live else {
468                continue;
469            };
470            if stream.key.capability_id != *capability_id {
471                continue;
472            }
473            let earliest = match stream.last_emitted_at {
474                Some(last) => schedule_after(last, floor).max(now),
475                None => now,
476            };
477            if earliest < stream.due_at {
478                stream.due_at = earliest;
479                moved = true;
480            }
481        }
482        moved
483    }
484
485    /// Earliest scheduled beat across live streams — the mesh
486    /// loop's sleep target. `None` = fully idle (zero emission).
487    pub fn next_due(&self) -> Option<Instant> {
488        self.slots
489            .values()
490            .filter_map(|slot| slot.live.as_ref().map(|stream| stream.due_at))
491            .min()
492    }
493
494    /// Phase 1 of two-phase emission (module docs): reserve and
495    /// re-arm every due stream under the lock, WITHOUT evaluating.
496    /// `generation` is the provider's OWN announce generation at
497    /// this instant — attested content, read at collection time
498    /// (§3.4). Evaluate each returned beat via [`DueBeat::request`]
499    /// outside the lock, then seal with [`DueBeat::into_unsigned`].
500    pub fn collect_due(&mut self, now: Instant, generation: u64) -> Vec<DueBeat> {
501        let mut due = Vec::new();
502        let stamp = self.touch();
503        for slot in self.slots.values_mut() {
504            let Some(stream) = &mut slot.live else {
505                continue;
506            };
507            if stream.due_at > now {
508                continue;
509            }
510            let seq = slot.next_seq;
511            slot.next_seq += 1;
512            slot.touched = stamp;
513            stream.last_emitted_at = Some(now);
514            stream.due_at = schedule_after(now, stream.promised_cadence);
515            due.push(DueBeat {
516                key: stream.key.clone(),
517                predicate: stream.predicate.clone(),
518                audience: stream.audience,
519                origin: self.origin,
520                incarnation: self.incarnation,
521                generation,
522                seq,
523                promised_cadence: stream.promised_cadence,
524                stamp,
525            });
526        }
527        due
528    }
529
530    /// One signed refusal beat for a below-floor registration
531    /// (module docs): `ProviderUnknown` /
532    /// `SamplingIntervalUnsupported`, the floor M in
533    /// `promised_cadence` (tagged interpretation), seq drawn from
534    /// the digest's shared slot. Mints a slot only because a
535    /// response IS being authored — capacity refusals never call
536    /// this.
537    pub fn refusal_beat(
538        &mut self,
539        spec: &InterestSpec,
540        refusal: CadenceRefusal,
541        generation: u64,
542    ) -> UnsignedAttestation {
543        let key = CapabilityInterestKey::for_spec(spec);
544        let digest = key.interest_digest;
545        let stamp = self.touch();
546        let slot = self.slots.entry(digest).or_insert(StreamSlot {
547            next_seq: 0,
548            touched: 0,
549            live: None,
550        });
551        slot.touched = stamp;
552        let seq = slot.next_seq;
553        slot.next_seq += 1;
554        let (status, status_reason) = refusal.as_status();
555        let beat = UnsignedAttestation {
556            interest_digest: digest,
557            origin: self.origin,
558            origin_incarnation: self.incarnation,
559            capability_id: key.capability_id,
560            capability_generation: generation,
561            status,
562            status_reason,
563            estimated_start: None,
564            seq,
565            promised_cadence: refusal.minimum_supported,
566            audience_scope: spec.audience,
567        };
568        self.evict_retired();
569        beat
570    }
571
572    /// Bound the slot map: oldest-touched RETIRED slots evict first;
573    /// live slots never evict (module docs).
574    fn evict_retired(&mut self) {
575        if self.slots.len() <= MAX_STREAM_SLOTS {
576            return;
577        }
578        let mut retired: Vec<(Digest256, u64)> = self
579            .slots
580            .iter()
581            .filter(|(_, slot)| slot.live.is_none())
582            .map(|(digest, slot)| (*digest, slot.touched))
583            .collect();
584        retired.sort_by_key(|(_, touched)| *touched);
585        let excess = self.slots.len().saturating_sub(STREAM_SLOTS_LOW_WATER);
586        for (digest, _) in retired.into_iter().take(excess) {
587            self.slots.remove(&digest);
588        }
589    }
590
591    /// Live stream count (tests + observability).
592    pub fn live_streams(&self) -> usize {
593        self.live_count
594    }
595
596    /// Total slot count including retired seq memory (tests +
597    /// observability).
598    pub fn slot_count(&self) -> usize {
599        self.slots.len()
600    }
601
602    /// A live stream's promised cadence (tests + observability).
603    pub fn stream_cadence(&self, digest: &Digest256) -> Option<Duration> {
604        self.slots
605            .get(digest)
606            .and_then(|slot| slot.live.as_ref())
607            .map(|stream| stream.promised_cadence)
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use std::time::{Duration, Instant};
614
615    use super::super::continuity::AttestedStatus;
616    use super::super::evaluator::StatusReason;
617    use super::super::identity::{DisclosureClass, ProviderSelector, ResultMode};
618    use super::*;
619
620    const FLOOR: Duration = DEFAULT_ATTESTATION_CADENCE_FLOOR;
621
622    fn spec(capability: &str, marker: &str) -> InterestSpec {
623        InterestSpec {
624            capability_id: CapabilityId::new(capability),
625            constraints: CanonicalConstraints::from_entries([("marker", marker)]).unwrap(),
626            work_latency: WorkLatencyEnvelope::start_within(Duration::from_millis(100)),
627            providers: ProviderSelector::AnyAuthorized,
628            result_mode: ResultMode::Any,
629            disclosure_class: DisclosureClass::Owner,
630            audience: AudienceScopeCommitment::from_bytes([7u8; 32]),
631        }
632    }
633
634    fn ready() -> Option<ReadinessEvaluation> {
635        Some(ReadinessEvaluation::Ready {
636            estimated_start: Some(Duration::from_millis(3)),
637        })
638    }
639
640    /// Collect + seal in one step for tests that don't exercise the
641    /// two-phase split itself.
642    fn beats(
643        emitter: &mut OriginEmitter,
644        now: Instant,
645        generation: u64,
646    ) -> Vec<(CapabilityInterestKey, UnsignedAttestation)> {
647        emitter
648            .collect_due(now, generation)
649            .into_iter()
650            .map(|beat| (beat.key().clone(), beat.into_unsigned(ready())))
651            .collect()
652    }
653
654    #[test]
655    fn first_beat_immediate_then_cadence_spacing_and_monotonic_seq() {
656        let t0 = Instant::now();
657        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
658        let spec = spec("job.run", "a");
659        // strictest 200 ms → cadence 100 ms.
660        emitter
661            .register(&spec, Duration::from_millis(200), t0)
662            .unwrap();
663        assert_eq!(emitter.next_due(), Some(t0));
664
665        let out = beats(&mut emitter, t0, 5);
666        assert_eq!(out.len(), 1);
667        let (key, beat) = &out[0];
668        assert_eq!(key.interest_digest, spec.interest_digest());
669        assert_eq!(beat.seq, 0);
670        assert_eq!(beat.capability_generation, 5);
671        assert_eq!(beat.status, AttestedStatus::Ready);
672        assert_eq!(beat.promised_cadence, Duration::from_millis(100));
673        assert_eq!(beat.origin, 11);
674
675        // Not due again until t0 + cadence; generation is read at
676        // collection time, not registration time.
677        assert!(beats(&mut emitter, t0 + Duration::from_millis(99), 6).is_empty());
678        let out = beats(&mut emitter, t0 + Duration::from_millis(100), 6);
679        assert_eq!(out.len(), 1);
680        assert_eq!(out[0].1.seq, 1);
681        assert_eq!(out[0].1.capability_generation, 6);
682    }
683
684    #[test]
685    fn two_phase_split_reserves_under_lock_and_seals_pure() {
686        let t0 = Instant::now();
687        let mut emitter = OriginEmitter::new(11, Incarnation::new(2), FLOOR);
688        let spec = spec("job.run", "a");
689        emitter
690            .register(&spec, Duration::from_millis(200), t0)
691            .unwrap();
692
693        // Phase 1 already reserved seq + re-armed the schedule …
694        let due = emitter.collect_due(t0, 9);
695        assert_eq!(due.len(), 1);
696        assert_eq!(
697            emitter.next_due(),
698            Some(t0 + Duration::from_millis(100)),
699            "schedule re-armed before evaluation",
700        );
701        assert!(
702            emitter.collect_due(t0, 9).is_empty(),
703            "seq/schedule reserved exactly once",
704        );
705
706        // … so sealing needs no emitter access at all, and the
707        // request borrows only the beat.
708        let beat = due.into_iter().next().unwrap();
709        assert_eq!(beat.request().capability_id.as_str(), "job.run");
710        let unsigned = beat.into_unsigned(None);
711        assert_eq!(unsigned.status, AttestedStatus::ProviderUnknown);
712        assert_eq!(unsigned.status_reason, StatusReason::TemporarilyUnevaluable);
713        assert_eq!(unsigned.seq, 0);
714        assert_eq!(unsigned.origin_incarnation, Incarnation::new(2));
715    }
716
717    #[test]
718    fn refresh_does_not_starve_cadence_but_tightening_reschedules() {
719        let t0 = Instant::now();
720        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
721        let spec = spec("job.run", "a");
722        emitter
723            .register(&spec, Duration::from_millis(400), t0)
724            .unwrap();
725        assert_eq!(
726            emitter.stream_cadence(&spec.interest_digest()),
727            Some(Duration::from_millis(200)),
728        );
729        let _ = beats(&mut emitter, t0, 1);
730        assert_eq!(emitter.next_due(), Some(t0 + Duration::from_millis(200)));
731
732        // Same-aggregate refresh (the ttl/2 keep-alive): schedule
733        // untouched.
734        emitter
735            .register(
736                &spec,
737                Duration::from_millis(400),
738                t0 + Duration::from_millis(50),
739            )
740            .unwrap();
741        assert_eq!(emitter.next_due(), Some(t0 + Duration::from_millis(200)));
742
743        // A stricter co-subscriber arrives: cadence 200 → 60 ms,
744        // next beat re-derived from the LAST beat (t0 + 60).
745        emitter
746            .register(
747                &spec,
748                Duration::from_millis(120),
749                t0 + Duration::from_millis(50),
750            )
751            .unwrap();
752        assert_eq!(
753            emitter.stream_cadence(&spec.interest_digest()),
754            Some(Duration::from_millis(60)),
755        );
756        assert_eq!(emitter.next_due(), Some(t0 + Duration::from_millis(60)));
757
758        // Cadence floors at the configured floor even for tiny D.
759        emitter
760            .register(&spec, FLOOR, t0 + Duration::from_millis(50))
761            .unwrap();
762        assert_eq!(emitter.stream_cadence(&spec.interest_digest()), Some(FLOOR));
763    }
764
765    #[test]
766    fn poke_pulls_forward_with_floor_min_gap() {
767        let t0 = Instant::now();
768        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
769        let spec = spec("job.run", "a");
770        emitter
771            .register(&spec, Duration::from_millis(400), t0)
772            .unwrap();
773        let _ = beats(&mut emitter, t0, 1);
774
775        // Edge right after a beat: clamped to last + floor.
776        assert!(emitter.poke(
777            &CapabilityId::new("job.run"),
778            t0 + Duration::from_millis(10)
779        ));
780        assert_eq!(emitter.next_due(), Some(t0 + FLOOR));
781
782        // Edge long after the last beat: immediate.
783        let late = t0 + Duration::from_millis(150);
784        let _ = beats(&mut emitter, t0 + FLOOR, 1);
785        assert!(emitter.poke(&CapabilityId::new("job.run"), late));
786        assert_eq!(emitter.next_due(), Some(late));
787
788        // Unknown capability: nothing moves.
789        assert!(!emitter.poke(&CapabilityId::new("other.cap"), late));
790    }
791
792    #[test]
793    fn refusal_beat_carries_floor_in_promised_cadence_and_shares_seq_space() {
794        let t0 = Instant::now();
795        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
796        let spec = spec("job.run", "a");
797
798        // Below-floor registration refused, stream stays dark.
799        let refused = emitter
800            .register(&spec, Duration::from_millis(10), t0)
801            .unwrap_err();
802        let StreamRefusal::Cadence(refusal) = refused else {
803            panic!("expected a cadence refusal, got {refused:?}");
804        };
805        assert_eq!(refusal.minimum_supported, FLOOR);
806        assert_eq!(emitter.live_streams(), 0);
807
808        let beat = emitter.refusal_beat(&spec, refusal, 9);
809        assert_eq!(beat.status, AttestedStatus::ProviderUnknown);
810        assert_eq!(
811            beat.status_reason,
812            StatusReason::SamplingIntervalUnsupported
813        );
814        assert_eq!(beat.promised_cadence, FLOOR);
815        assert_eq!(beat.estimated_start, None);
816        assert_eq!(beat.seq, 0);
817
818        // A later legal registration continues the SAME seq space —
819        // refusal beat consumed seq 0, first stream beat is seq 1.
820        emitter
821            .register(&spec, Duration::from_millis(200), t0)
822            .unwrap();
823        let out = beats(&mut emitter, t0, 9);
824        assert_eq!(out.len(), 1);
825        assert_eq!(out[0].1.seq, 1);
826    }
827
828    #[test]
829    fn retire_stops_emission_and_resurrection_keeps_seq() {
830        let t0 = Instant::now();
831        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
832        let spec = spec("job.run", "a");
833        emitter
834            .register(&spec, Duration::from_millis(200), t0)
835            .unwrap();
836        let _ = beats(&mut emitter, t0, 1);
837
838        // Zero idle emission: retired stream leaves nothing due.
839        emitter.retire(&spec.interest_digest());
840        assert_eq!(emitter.live_streams(), 0);
841        assert_eq!(emitter.next_due(), None);
842        assert!(beats(&mut emitter, t0 + Duration::from_secs(5), 1).is_empty());
843
844        // Resurrection continues the seq space (no equivocation on
845        // (incarnation, seq) within one incarnation).
846        emitter
847            .register(
848                &spec,
849                Duration::from_millis(200),
850                t0 + Duration::from_secs(6),
851            )
852            .unwrap();
853        let out = beats(&mut emitter, t0 + Duration::from_secs(6), 1);
854        assert_eq!(out[0].1.seq, 1);
855    }
856
857    #[test]
858    fn compile_once_per_distinct_digest_and_per_interest_streams() {
859        let t0 = Instant::now();
860        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
861        let a = spec("job.run", "a");
862        let b = spec("job.run", "b");
863        emitter
864            .register(&a, Duration::from_millis(200), t0)
865            .unwrap();
866        emitter
867            .register(&b, Duration::from_millis(400), t0)
868            .unwrap();
869        assert_eq!(emitter.live_streams(), 2);
870
871        // Distinct digests emit independent streams with their own
872        // seq spaces and cadences.
873        let out = beats(&mut emitter, t0, 1);
874        assert_eq!(out.len(), 2);
875        assert!(out.iter().all(|(_, beat)| beat.seq == 0));
876        let out = beats(&mut emitter, t0 + Duration::from_millis(100), 1);
877        assert_eq!(out.len(), 1);
878        assert_eq!(out[0].0.interest_digest, a.interest_digest());
879    }
880
881    #[test]
882    fn live_capacity_refuses_new_digests_never_evicts_and_mints_no_slot() {
883        let t0 = Instant::now();
884        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
885        let mut specs = Vec::new();
886        for i in 0..MAX_LIVE_SENSING_STREAMS {
887            let s = spec("job.run", &format!("live-{i}"));
888            emitter
889                .register(&s, Duration::from_millis(200), t0)
890                .unwrap();
891            specs.push(s);
892        }
893        assert_eq!(emitter.live_streams(), MAX_LIVE_SENSING_STREAMS);
894        let slots_at_cap = emitter.slot_count();
895
896        // A new digest is refused, WITHOUT minting a seq slot.
897        let overflow = spec("job.run", "overflow");
898        assert_eq!(
899            emitter.register(&overflow, Duration::from_millis(200), t0),
900            Err(StreamRefusal::AtCapacity),
901        );
902        assert_eq!(emitter.live_streams(), MAX_LIVE_SENSING_STREAMS);
903        assert_eq!(emitter.slot_count(), slots_at_cap, "no slot minted");
904
905        // A refresh of an EXISTING live digest is accepted at cap.
906        assert!(emitter
907            .register(&specs[0], Duration::from_millis(120), t0)
908            .is_ok());
909        assert_eq!(
910            emitter.stream_cadence(&specs[0].interest_digest()),
911            Some(Duration::from_millis(60)),
912        );
913
914        // A retired digest is a RESURRECTION — refused at cap …
915        emitter.retire(&specs[1].interest_digest());
916        emitter
917            .register(&overflow, Duration::from_millis(200), t0)
918            .expect("one live slot freed");
919        assert_eq!(emitter.live_streams(), MAX_LIVE_SENSING_STREAMS);
920        assert_eq!(
921            emitter.register(&specs[1], Duration::from_millis(200), t0),
922            Err(StreamRefusal::AtCapacity),
923            "resurrection counts against the live cap",
924        );
925    }
926
927    #[test]
928    fn stamped_retire_skips_streams_registered_after_the_snapshot() {
929        let t0 = Instant::now();
930        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
931        let spec = spec("job.run", "a");
932        emitter
933            .register(&spec, Duration::from_millis(200), t0)
934            .unwrap();
935        let digest = spec.interest_digest();
936
937        // Snapshot, then a registration lands (the race): the stale
938        // retire must be refused.
939        let seen = emitter.stamp();
940        emitter
941            .register(
942                &spec,
943                Duration::from_millis(200),
944                t0 + Duration::from_millis(5),
945            )
946            .unwrap();
947        assert!(!emitter.retire_if_stale(&digest, seen));
948        assert_eq!(emitter.live_streams(), 1);
949
950        // A fresh snapshot with no interleaving registration
951        // retires normally.
952        let seen = emitter.stamp();
953        assert!(emitter.retire_if_stale(&digest, seen));
954        assert_eq!(emitter.live_streams(), 0);
955        assert!(!emitter.retire_if_stale(&digest, seen), "idempotent");
956    }
957
958    #[test]
959    fn absurd_durations_never_panic_and_park_instead_of_hot_looping() {
960        let t0 = Instant::now();
961        // Zero floor is normalized — a zero cadence can never admit.
962        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), Duration::ZERO);
963        let spec = spec("job.run", "a");
964        assert!(matches!(
965            emitter.register(&spec, Duration::from_millis(1), t0),
966            Err(StreamRefusal::Cadence(_)),
967        ));
968
969        // A near-MAX interval schedules without panicking, and the
970        // overflowed re-arm parks far out instead of going due
971        // immediately.
972        emitter.register(&spec, Duration::MAX, t0).unwrap();
973        let out = beats(&mut emitter, t0, 1);
974        assert_eq!(out.len(), 1, "first beat still fires");
975        let next = emitter.next_due().expect("stream live");
976        assert!(
977            next > t0 + Duration::from_secs(60 * 60 * 24 * 30),
978            "overflowed schedule parks far in the future",
979        );
980        assert!(
981            beats(&mut emitter, t0 + Duration::from_secs(1), 1).is_empty(),
982            "no hot loop",
983        );
984    }
985
986    #[test]
987    fn retired_slots_evict_oldest_first_live_never() {
988        let t0 = Instant::now();
989        let mut emitter = OriginEmitter::new(11, Incarnation::new(1), FLOOR);
990        // One live stream that must survive the sweep.
991        let live = spec("job.run", "live");
992        emitter
993            .register(&live, Duration::from_millis(200), t0)
994            .unwrap();
995        // Flood retired slots past the cap.
996        for i in 0..MAX_STREAM_SLOTS {
997            let s = spec("job.run", &format!("retired-{i}"));
998            emitter
999                .register(&s, Duration::from_millis(200), t0)
1000                .unwrap();
1001            emitter.retire(&s.interest_digest());
1002        }
1003        assert!(emitter.slot_count() <= STREAM_SLOTS_LOW_WATER + 1);
1004        assert_eq!(emitter.live_streams(), 1);
1005        assert_eq!(
1006            emitter.stream_cadence(&live.interest_digest()),
1007            Some(Duration::from_millis(100)),
1008        );
1009    }
1010}