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}