Skip to main content

ados_offload/
freshness.rs

1//! The link-aware freshness gate and the lock-state safety gate.
2//!
3//! Remote perception is near-real-time, not real-time (200-500 ms on a direct
4//! LAN, more on a relay). The one invariant the whole offload path is built
5//! around: **a result past its freshness budget, or a dropped link, is treated
6//! as absent, never held forward, never extrapolated.** A behaviour that has a
7//! lock loses it (stop and hold) and **never auto-re-acquires**; only an explicit
8//! re-designation re-locks.
9
10use serde::{Deserialize, Serialize};
11
12/// The freshness of one offloaded stream (a detection/target stream, or a pose
13/// stream), derived from the last result's age and the link state.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum GateState {
17    /// A result arrived within the budget and the link is up: usable.
18    Fresh,
19    /// A result arrived but it is older than the budget: not usable (stop).
20    Stale,
21    /// No result yet, or the link is down: not usable (stop).
22    Lost,
23}
24
25impl GateState {
26    /// Only a `Fresh` reading may drive a behaviour. `Stale` and `Lost` both mean
27    /// stop and hold.
28    pub fn is_usable(self) -> bool {
29        matches!(self, GateState::Fresh)
30    }
31}
32
33/// Tracks the freshness of one offloaded result stream.
34///
35/// Freshness is anchored on the **local monotonic time the result arrived**, not
36/// on the result's own (remote) timestamp. This is deliberate and load-bearing:
37/// the drone and the compute node are not clock-synced, so a node whose clock is
38/// skewed ahead, whose detector then hangs (the socket staying up, so no link
39/// drop fires), would keep a frozen stream reading fresh forever if age were
40/// measured from the remote timestamp; the aircraft would follow a frozen
41/// target. Measuring "time since a result last arrived, on the drone's own
42/// monotonic clock" catches exactly that stall. The `now_ms` passed to
43/// [`record`](Self::record) and
44/// [`state`](Self::state) MUST come from the same local monotonic clock; the
45/// remote result timestamp is for telemetry, never for this gate.
46#[derive(Debug, Clone)]
47pub struct FreshnessGate {
48    budget_ms: i64,
49    last_arrival_ms: Option<i64>,
50    link_up: bool,
51}
52
53impl FreshnessGate {
54    /// A gate with the stream's freshness `budget_ms` (e.g. a target-age or a
55    /// pose-age budget). A negative budget is clamped to 0 (a misconfiguration
56    /// must not flip the fail-safe direction). Starts `Lost` (no result yet);
57    /// the link starts up.
58    pub fn new(budget_ms: i64) -> Self {
59        Self {
60            budget_ms: budget_ms.max(0),
61            last_arrival_ms: None,
62            link_up: true,
63        }
64    }
65
66    /// Record that a result arrived. `now_ms` is the **local monotonic time of
67    /// arrival** (NOT the result's own timestamp), see the type docs.
68    pub fn record(&mut self, now_ms: i64) {
69        self.last_arrival_ms = Some(now_ms);
70    }
71
72    /// Set the link state (a drop trips the gate to `Lost` regardless of the
73    /// last result's age).
74    pub fn set_link(&mut self, up: bool) {
75        self.link_up = up;
76    }
77
78    /// The gate state at the local monotonic time `now_ms`.
79    pub fn state(&self, now_ms: i64) -> GateState {
80        if !self.link_up {
81            return GateState::Lost;
82        }
83        match self.last_arrival_ms {
84            None => GateState::Lost,
85            // now_ms and the arrival are on the same monotonic clock, so the age
86            // is non-negative; saturating_sub guards a caller that violates that.
87            Some(arrival) if now_ms.saturating_sub(arrival) <= self.budget_ms => GateState::Fresh,
88            Some(_) => GateState::Stale,
89        }
90    }
91
92    /// Whether a behaviour may act on this stream at `now_ms`.
93    pub fn is_usable(&self, now_ms: i64) -> bool {
94        self.state(now_ms).is_usable()
95    }
96}
97
98/// The lock-state machine. A behaviour locks onto a target (an operator
99/// designate, or an auto-lock). While `Locked` it is *acquiring* until the
100/// stream first becomes usable; once it has had a usable reading, the stream
101/// going stale or the link dropping drops it to `Lost`. The only way out of
102/// `Lost` is an explicit re-lock, there is no auto-re-acquire. A lock that has
103/// never yet had a usable reading does NOT drop to `Lost` (there is nothing to
104/// lose); it stays `Locked`, holding, until a result arrives or it is released.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum LockState {
108    /// No lock (idle).
109    Unlocked,
110    /// Locked: either acquiring (no usable reading yet) or tracking.
111    Locked,
112    /// The lock was dropped (a stream that HAD been usable went stale / the link
113    /// dropped). Stays here until an explicit re-lock; never auto-re-acquires.
114    Lost,
115}
116
117/// The lock-state safety gate.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct LockGate {
120    state: LockState,
121    /// Whether the stream has been usable at least once since the current lock.
122    /// Until it has, a not-usable stream is "acquiring", not "lost".
123    ever_usable_since_lock: bool,
124}
125
126impl Default for LockGate {
127    fn default() -> Self {
128        Self {
129            state: LockState::Unlocked,
130            ever_usable_since_lock: false,
131        }
132    }
133}
134
135impl LockGate {
136    /// An unlocked gate.
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// The current lock state.
142    pub fn state(&self) -> LockState {
143        self.state
144    }
145
146    /// Whether the behaviour is locked (acquiring or tracking). `commanding` (a
147    /// behaviour actually driving the FC) additionally requires the stream to be
148    /// usable this cycle; the session combines the two.
149    pub fn is_locked(&self) -> bool {
150        self.state == LockState::Locked
151    }
152
153    /// Explicitly acquire (or re-acquire) the lock, an operator designate. This
154    /// is the ONLY transition out of `Lost`.
155    pub fn lock(&mut self) {
156        self.state = LockState::Locked;
157        self.ever_usable_since_lock = false;
158    }
159
160    /// Release the lock (back to idle).
161    pub fn unlock(&mut self) {
162        self.state = LockState::Unlocked;
163        self.ever_usable_since_lock = false;
164    }
165
166    /// Advance the gate for one cycle given whether the underlying stream is
167    /// usable (fresh + link up). A `Locked` gate that HAS been usable and then
168    /// is not drops to `Lost`; a `Locked` gate still acquiring (never usable yet)
169    /// stays `Locked`. A `Lost` gate stays `Lost` even when the stream becomes
170    /// usable again, it never auto-re-acquires.
171    pub fn update(&mut self, stream_usable: bool) {
172        if self.state != LockState::Locked {
173            return;
174        }
175        if stream_usable {
176            self.ever_usable_since_lock = true;
177        } else if self.ever_usable_since_lock {
178            self.state = LockState::Lost;
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn a_fresh_gate_is_lost_until_a_result_arrives() {
189        let mut g = FreshnessGate::new(500);
190        assert_eq!(g.state(1000), GateState::Lost);
191        g.record(1000);
192        assert_eq!(g.state(1100), GateState::Fresh); // 100ms <= 500
193    }
194
195    #[test]
196    fn a_result_past_the_budget_is_stale() {
197        let mut g = FreshnessGate::new(500);
198        g.record(1000);
199        assert_eq!(g.state(1400), GateState::Fresh); // 400 <= 500
200        assert_eq!(g.state(1501), GateState::Stale); // 501 > 500
201        assert!(!g.is_usable(1501));
202    }
203
204    #[test]
205    fn a_link_drop_is_lost_even_with_a_fresh_result() {
206        let mut g = FreshnessGate::new(500);
207        g.record(1000);
208        g.set_link(false);
209        assert_eq!(g.state(1050), GateState::Lost);
210        g.set_link(true);
211        assert_eq!(g.state(1050), GateState::Fresh);
212    }
213
214    #[test]
215    fn a_frozen_stream_goes_stale_on_local_elapsed_not_the_result_timestamp() {
216        // The safety regression: anchored on local arrival, a stream that stops
217        // arriving goes Stale once the LOCAL clock passes the budget, a remote
218        // clock skew or a frozen-but-socket-up node can no longer mask staleness.
219        let mut g = FreshnessGate::new(500);
220        g.record(1000); // arrived at local time 1000
221        assert_eq!(g.state(1400), GateState::Fresh);
222        // No new result; local time advances past the budget.
223        assert_eq!(g.state(1501), GateState::Stale);
224        assert_eq!(g.state(100_000), GateState::Stale); // stays stale forever
225    }
226
227    #[test]
228    fn the_budget_boundary_is_inclusive() {
229        let mut g = FreshnessGate::new(500);
230        g.record(1000);
231        assert_eq!(g.state(1500), GateState::Fresh); // age == budget is fresh
232        assert_eq!(g.state(1501), GateState::Stale); // age == budget+1 is stale
233    }
234
235    #[test]
236    fn a_negative_budget_is_clamped_not_permanently_stale() {
237        let mut g = FreshnessGate::new(-100);
238        g.record(1000);
239        // Clamped to 0: an age-0 reading is still fresh (not always-stale).
240        assert_eq!(g.state(1000), GateState::Fresh);
241        assert_eq!(g.state(1001), GateState::Stale);
242    }
243
244    #[test]
245    fn the_lock_gate_starts_unlocked() {
246        assert_eq!(LockGate::new().state(), LockState::Unlocked);
247    }
248
249    #[test]
250    fn a_locked_gate_stays_locked_while_the_stream_is_usable() {
251        let mut g = LockGate::new();
252        g.lock();
253        g.update(true);
254        assert_eq!(g.state(), LockState::Locked);
255        assert!(g.is_locked());
256    }
257
258    #[test]
259    fn a_lock_still_acquiring_stays_locked_not_lost() {
260        // Designated but the stream has not produced a usable reading yet: this
261        // is acquiring, not lost, the behaviour holds, it has nothing to lose.
262        let mut g = LockGate::new();
263        g.lock();
264        g.update(false);
265        g.update(false);
266        assert_eq!(g.state(), LockState::Locked);
267    }
268
269    #[test]
270    fn a_tracking_gate_drops_to_lost_when_the_stream_goes_stale() {
271        let mut g = LockGate::new();
272        g.lock();
273        g.update(true); // acquired (a usable reading)
274        g.update(false); // then stale / link-lost -> lost
275        assert_eq!(g.state(), LockState::Lost);
276        assert!(!g.is_locked());
277    }
278
279    #[test]
280    fn a_lost_gate_never_auto_re_acquires() {
281        // The single most important safety invariant.
282        let mut g = LockGate::new();
283        g.lock();
284        g.update(true); // acquire
285        g.update(false); // -> Lost
286        assert_eq!(g.state(), LockState::Lost);
287        // The stream becomes usable again; the gate must NOT re-lock by itself.
288        g.update(true);
289        g.update(true);
290        assert_eq!(g.state(), LockState::Lost);
291        // Only an explicit re-designate re-acquires.
292        g.lock();
293        assert_eq!(g.state(), LockState::Locked);
294    }
295
296    #[test]
297    fn an_unlocked_gate_does_not_lock_itself_on_a_usable_stream() {
298        let mut g = LockGate::new();
299        g.update(true); // a usable stream must not auto-lock an idle gate
300        assert_eq!(g.state(), LockState::Unlocked);
301    }
302
303    #[test]
304    fn unlock_returns_to_idle() {
305        let mut g = LockGate::new();
306        g.lock();
307        g.unlock();
308        assert_eq!(g.state(), LockState::Unlocked);
309    }
310}