1use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10use forge_primitives::SessionSlot;
11
12use crate::surface::inspector::ContextUsage;
13
14const MIN_INTERVAL: Duration = Duration::from_secs(60);
17
18const TOKEN_GATE: u64 = 500_000;
23
24#[derive(Default)]
26pub struct ContextProbe {
27 last_asked: HashMap<SessionSlot, Instant>,
29}
30
31#[derive(Clone, Copy, PartialEq, Eq, Debug)]
34pub enum Declined {
35 LargeTranscript,
38 RecentlyAsked,
40}
41
42impl ContextProbe {
43 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 pub fn force(&mut self, slot: &SessionSlot, now: Instant) {
75 self.expire(now);
76 self.last_asked.insert(slot.clone(), now);
77 }
78
79 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 fn expire(&mut self, now: Instant) {
89 self.last_asked.retain(|_, last| now.duration_since(*last) < MIN_INTERVAL);
90 }
91}
92
93fn 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 #[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 #[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 #[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 #[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 #[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}