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}