Skip to main content

forge_server/transport/
probe.rs

1//! The socket's own bound on asking the core for a fresh context reading, the
2//! same two numbers a view bounds its ask with: a probe is answered inline
3//! over the CLI's whole transcript, so it is worth asking for only when the
4//! reading it would replace is old enough and the transcript small enough to
5//! pay for it.
6
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10use forge_primitives::SessionSlot;
11
12use crate::surface::inspector::ContextUsage;
13
14/// How long a seat's reading stands before the socket asks for another, the
15/// same bound a view puts on its own ask.
16const MIN_INTERVAL: Duration = Duration::from_secs(60);
17
18/// Used tokens at or above which the socket does not ask at all, the same
19/// gate a view puts on its own ask: only a `[1m]`-class window reaches this
20/// size, where the CLI's walk over the transcript can exceed the hook
21/// timeout.
22const TOKEN_GATE: u64 = 500_000;
23
24/// The socket's bound on asking, held for the life of the fold.
25#[derive(Default)]
26pub struct ContextProbe {
27    /// When the socket last asked for each seat's reading.
28    last_asked: HashMap<SessionSlot, Instant>,
29}
30
31/// Why the socket did not ask a seat a page shows, so a reading that has
32/// stopped moving is answered from the log rather than from a repro.
33#[derive(Clone, Copy, PartialEq, Eq, Debug)]
34pub enum Declined {
35    /// The transcript is big enough that the CLI's computation over it can
36    /// exceed the hook timeout.
37    LargeTranscript,
38    /// The seat was asked for a reading less than [`MIN_INTERVAL`] ago.
39    RecentlyAsked,
40}
41
42impl ContextProbe {
43    /// Whether the socket may ask for a fresh reading on `slot`, recording the
44    /// ask when it may.
45    ///
46    /// The reading arrives as a thunk because it is built under the session's
47    /// lock, and the interval can refuse the ask without it - so a fold that
48    /// took the reading first would hold that lock once per finished turn, on
49    /// the one task that serves every client.
50    pub fn admit(
51        &mut self,
52        slot: &SessionSlot,
53        reading: impl FnOnce() -> ContextUsage,
54        now: Instant,
55    ) -> Result<(), Declined> {
56        self.expire(now);
57        if self.too_soon(slot, now) {
58            return Err(Declined::RecentlyAsked);
59        }
60        if used_tokens(&reading()).is_some_and(|used| used >= TOKEN_GATE) {
61            return Err(Declined::LargeTranscript);
62        }
63        self.last_asked.insert(slot.clone(), now);
64        Ok(())
65    }
66
67    /// Record an ask made past both bounds, which is the one case they must not
68    /// refuse: a compaction settled, so the held reading was taken from a
69    /// transcript that no longer exists.
70    ///
71    /// Both bounds read the reading they would be replacing, so without this a
72    /// seat past the token gate refuses every ask and its number stands at the
73    /// high value for the life of its occupant.
74    pub fn force(&mut self, slot: &SessionSlot, now: Instant) {
75        self.expire(now);
76        self.last_asked.insert(slot.clone(), now);
77    }
78
79    /// Whether `slot` was asked inside the interval, without building a
80    /// reading to find out.
81    fn too_soon(&self, slot: &SessionSlot, now: Instant) -> bool {
82        self.last_asked.get(slot).is_some_and(|last| now.duration_since(*last) < MIN_INTERVAL)
83    }
84
85    /// Drop the seats whose last ask has aged out: the fold has no seat-closed
86    /// signal to hang a removal on, and an entry older than the interval
87    /// refuses nothing, so expiry is the whole of the bound.
88    fn expire(&mut self, now: Instant) {
89        self.last_asked.retain(|_, last| now.duration_since(*last) < MIN_INTERVAL);
90    }
91}
92
93/// The tokens `reading` implies have been used, `None` when the seat reports
94/// only one half of the sum - a seat that cannot be measured, which is asked
95/// rather than gated.
96fn used_tokens(reading: &ContextUsage) -> Option<u64> {
97    let percent = u64::from(reading.percent?);
98    Some(percent.saturating_mul(reading.max_tokens?) / 100)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::{ContextProbe, Declined, MIN_INTERVAL, TOKEN_GATE};
104    use crate::surface::inspector::ContextUsage;
105    use forge_primitives::SessionSlot;
106    use std::time::Instant;
107
108    fn seat(label: &str) -> SessionSlot {
109        SessionSlot::lead("TestOrg", label)
110    }
111
112    fn reading(percent: u8, max_tokens: u64) -> ContextUsage {
113        ContextUsage { percent: Some(percent), max_tokens: Some(max_tokens) }
114    }
115
116    /// Only a window large enough for the CLI's own walk over the transcript
117    /// to exceed the hook timeout is refused, and a reading that reports one
118    /// half of the sum without the other is asked for rather than assumed
119    /// large: the gate bounds a cost, and an unmeasured cost is not a refusal.
120    #[test]
121    fn a_transcript_too_large_to_recompute_is_not_asked() {
122        let mut probes = ContextProbe::default();
123        let now = Instant::now();
124        let window = 1_000_000;
125        let at_the_gate = u8::try_from(TOKEN_GATE * 100 / window).expect("a percentage");
126
127        assert_eq!(
128            probes.admit(&seat("big"), || reading(at_the_gate, window), now),
129            Err(Declined::LargeTranscript),
130            "a transcript at the token gate exactly is not asked for",
131        );
132        assert_eq!(
133            probes.admit(&seat("small"), || reading(at_the_gate - 1, window), now),
134            Ok(()),
135            "and one below it is, which is the whole of the gate",
136        );
137        assert_eq!(
138            probes.admit(
139                &seat("unmeasured"),
140                || ContextUsage { percent: Some(99), max_tokens: None },
141                now,
142            ),
143            Ok(()),
144            "a reading carrying half the sum is asked for rather than read as large",
145        );
146    }
147
148    /// The interval is a bound, not a latch: a seat asked inside it is refused,
149    /// and the same seat past it is asked again - which is what keeps a reading
150    /// from standing for the life of its occupant.
151    #[test]
152    fn a_seat_asked_within_the_interval_is_not_asked_again() {
153        let mut probes = ContextProbe::default();
154        let now = Instant::now();
155        let small = || reading(12, 200_000);
156
157        assert_eq!(probes.admit(&seat("busy"), small, now), Ok(()), "the first ask goes");
158        assert_eq!(
159            probes.admit(&seat("busy"), small, now + MIN_INTERVAL / 2),
160            Err(Declined::RecentlyAsked),
161            "and the same seat inside the interval is refused",
162        );
163        assert_eq!(
164            probes.admit(&seat("busy"), small, now + MIN_INTERVAL),
165            Ok(()),
166            "past it the seat is askable again",
167        );
168        assert_eq!(
169            probes.admit(&seat("busy"), small, now + MIN_INTERVAL),
170            Err(Declined::RecentlyAsked),
171            "and the ask just made restarts the interval rather than leaving it open",
172        );
173    }
174
175    /// One fold serves every seat, so the interval has to be the seat's own: a
176    /// bound held for the socket rather than for the seat would refuse a page's
177    /// probe because some other seat finished a turn.
178    ///
179    /// The refusal of the seat that asked is here as well as the allowance of
180    /// the one that did not, because the allowance alone holds with the
181    /// interval gone - an assertion that both seats are let past pins nothing.
182    #[test]
183    fn the_interval_is_held_per_seat() {
184        let mut probes = ContextProbe::default();
185        let now = Instant::now();
186        let small = || reading(12, 200_000);
187
188        assert_eq!(probes.admit(&seat("busy"), small, now), Ok(()), "one seat asks");
189        assert_eq!(
190            probes.admit(&seat("busy"), small, now + MIN_INTERVAL / 2),
191            Err(Declined::RecentlyAsked),
192            "and is refused inside the interval",
193        );
194        assert_eq!(
195            probes.admit(&seat("quiet"), small, now + MIN_INTERVAL / 2),
196            Ok(()),
197            "while a seat that was never asked is not refused by it",
198        );
199    }
200
201    /// An entry older than the interval refuses nothing, so it is dropped
202    /// rather than held for the life of the process - one per seat the socket
203    /// has ever asked, on a machine that has spawned workers since boot.
204    #[test]
205    fn a_seat_that_asked_long_ago_is_not_held() {
206        let mut probes = ContextProbe::default();
207        let now = Instant::now();
208        let small = || reading(12, 200_000);
209
210        assert_eq!(probes.admit(&seat("old"), small, now), Ok(()), "one seat asks");
211        assert_eq!(probes.last_asked.len(), 1, "precondition: the ask is held");
212        assert_eq!(
213            probes.admit(&seat("new"), small, now + MIN_INTERVAL),
214            Ok(()),
215            "and a second asks past the interval",
216        );
217        assert_eq!(
218            probes.last_asked.len(),
219            1,
220            "the first seat's ask is gone rather than standing beside it: {:?}",
221            probes.last_asked.keys().collect::<Vec<_>>(),
222        );
223    }
224
225    /// The one ask the bounds may not refuse: a compaction settled, so the
226    /// reading the gate would read was taken from a transcript that is gone.
227    /// Without this a seat past the token gate refuses every ask and its number
228    /// stands at the high value for the life of its occupant.
229    #[test]
230    fn a_forced_ask_goes_past_both_bounds() {
231        let mut probes = ContextProbe::default();
232        let now = Instant::now();
233        let huge = || reading(99, 1_000_000);
234
235        assert_eq!(
236            probes.admit(&seat("full"), huge, now),
237            Err(Declined::LargeTranscript),
238            "precondition: the gate refuses this seat's ordinary ask",
239        );
240
241        probes.force(&seat("full"), now);
242        assert_eq!(
243            probes.admit(&seat("full"), huge, now + MIN_INTERVAL / 2),
244            Err(Declined::RecentlyAsked),
245            "and the forced ask took the seat's place in the interval like any other",
246        );
247    }
248}