Skip to main content

runtime/prefix/
warmth.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! One session's keep-warm account: the last real request each prefix
7//! carried, and the renewal sent for it when `kernel::keep_warm` says it
8//! is due (`crates/runtime/spec/Prefix.lean` ยง8-4-2).
9//!
10//! **Off keeps nothing.** Under [`KeepWarm::Off`] a request is not even
11//! cloned, so the default setting costs no memory per session and has no
12//! renewal it could send.
13
14use std::collections::BTreeMap;
15
16use kernel::keep_warm::{CacheUse, KeepWarm, renewal_due};
17use kernel::{AxError, B3Hash, Ceiling, Increments, Model, ModelRequest, ModelReturn, TimeMs};
18
19/// Keep-warm bookkeeping for the prefixes one session has sent.
20#[derive(Debug, Clone)]
21pub struct Warmth {
22    setting: KeepWarm,
23    lead_ms: u64,
24    kept: BTreeMap<[B3Hash; 4], Kept>,
25}
26
27/// A prefix's last real request, and its cache as last seen.
28#[derive(Debug, Clone)]
29struct Kept {
30    request: ModelRequest<'static>,
31    cache: CacheUse,
32}
33
34impl Warmth {
35    /// `lead_ms` is the round trip to the provider as the caller measured
36    /// it: each renewal leaves that long before the cache expires.
37    pub fn new(setting: KeepWarm, lead_ms: u64) -> Warmth {
38        Warmth {
39            setting,
40            lead_ms,
41            kept: BTreeMap::new(),
42        }
43    }
44
45    /// A real request carrying `request.segments` was sent at `at_ms`.
46    pub fn sent(&mut self, request: &ModelRequest, at_ms: u64) {
47        match self.setting {
48            KeepWarm::Off => return,
49            KeepWarm::FiveMinute => {}
50        }
51        self.kept.insert(
52            request.segments,
53            Kept {
54                request: request.clone().into_owned(),
55                cache: CacheUse::sent(at_ms),
56            },
57        );
58    }
59
60    /// The instant the earliest renewal is sent, or `None` when no prefix
61    /// is to be renewed and a timer need not wake.
62    pub fn next_due(&self) -> Option<u64> {
63        self.kept
64            .values()
65            .filter_map(|kept| renewal_due(self.setting, kept.cache, self.lead_ms))
66            .min()
67    }
68
69    /// Sends, through `model`, every renewal due by `now_ms` and returns
70    /// each one's answer, whose usage the caller records like any other.
71    ///
72    /// # Errors
73    /// The model's failure, unchanged; the prefixes not yet renewed stay
74    /// for the next wake.
75    pub fn renew_due(
76        &mut self,
77        model: &mut dyn Model,
78        now_ms: u64,
79    ) -> Result<Vec<ModelReturn>, AxError> {
80        let (setting, lead_ms) = (self.setting, self.lead_ms);
81        self.kept
82            .retain(|_, kept| renewal_due(setting, kept.cache, lead_ms).is_some());
83        let mut answers = Vec::new();
84        for kept in self.kept.values_mut() {
85            if renewal_due(setting, kept.cache, lead_ms).is_some_and(|due| due <= now_ms) {
86                answers.push(model.call(&renewal_of(&kept.request))?);
87                kept.cache = kept.cache.renewed(now_ms);
88            }
89        }
90        Ok(answers)
91    }
92}
93
94/// The last real request, asking for one output token: the cache hits on
95/// the prefix bytes whatever the ceiling, so one token is the lowest
96/// price a renewal can pay.
97fn renewal_of(request: &ModelRequest<'static>) -> ModelRequest<'static> {
98    let mut renewal = request.clone();
99    renewal.chat.max_tokens = Ceiling::new(1);
100    renewal
101}
102
103/// The door a run's requests enter the keep-warm account by: it owns the
104/// adapter a run calls and is itself the [`Model`] the turn loop is
105/// handed, so every request that reached the provider is recorded
106/// without the turn loop knowing keep-warm exists.
107///
108/// The clock is the caller's, injected from the one sampling point; the
109/// round trip it measures around each call is the lead the next renewal
110/// leaves with, so the lead follows the provider this door reaches.
111pub struct Warmed<C> {
112    model: Box<dyn Model + Send>,
113    warmth: Warmth,
114    clock: C,
115}
116
117impl<C: FnMut() -> Result<TimeMs, AxError>> Warmed<C> {
118    /// Wraps `model`; under [`KeepWarm::Off`] it only forwards.
119    pub fn new(model: Box<dyn Model + Send>, setting: KeepWarm, clock: C) -> Warmed<C> {
120        Warmed {
121            model,
122            warmth: Warmth::new(setting, 0),
123            clock,
124        }
125    }
126
127    /// See [`Warmth::next_due`].
128    pub fn next_due(&self) -> Option<u64> {
129        self.warmth.next_due()
130    }
131
132    /// Sends every renewal due by `now_ms` through the adapter this door
133    /// owns; see [`Warmth::renew_due`].
134    ///
135    /// # Errors
136    /// The model's failure, unchanged.
137    pub fn renew_due(&mut self, now_ms: u64) -> Result<Vec<ModelReturn>, AxError> {
138        let left = (self.clock)()?;
139        let answers = self.warmth.renew_due(self.model.as_mut(), now_ms)?;
140        let back = (self.clock)()?;
141        // A renewal asks for one token, so its round trip is the closest
142        // reading of the lead. Several in one wake are timed together,
143        // which can only send the next ones earlier, never late.
144        if !answers.is_empty() {
145            self.warmth.lead_ms = back.value().saturating_sub(left.value());
146        }
147        Ok(answers)
148    }
149
150    /// Makes one real call through `send` and, when the provider
151    /// answered, records it with the round trip it took.
152    fn recorded(
153        &mut self,
154        request: &ModelRequest,
155        send: impl FnOnce(&mut dyn Model) -> Result<ModelReturn, AxError>,
156    ) -> Result<ModelReturn, AxError> {
157        let left = (self.clock)()?;
158        let answer = send(self.model.as_mut())?;
159        let back = (self.clock)()?;
160        self.warmth.lead_ms = back.value().saturating_sub(left.value());
161        self.warmth.sent(request, left.value());
162        Ok(answer)
163    }
164}
165
166impl<C: FnMut() -> Result<TimeMs, AxError>> Model for Warmed<C> {
167    fn call(&mut self, req: &ModelRequest) -> Result<ModelReturn, AxError> {
168        self.recorded(req, |model| model.call(req))
169    }
170
171    fn call_streaming(
172        &mut self,
173        req: &ModelRequest,
174        onto: Increments<'_>,
175    ) -> Result<ModelReturn, AxError> {
176        self.recorded(req, |model| model.call_streaming(req, onto))
177    }
178}
179
180#[cfg(test)]
181#[allow(
182    clippy::unwrap_used,
183    clippy::expect_used,
184    clippy::indexing_slicing,
185    clippy::arithmetic_side_effects
186)]
187mod tests {
188    use super::*;
189    use kernel::consts_external::PROMPT_CACHE_TTL_SECS;
190    use kernel::{BuildingPolicy, ChatRequest, ContentBlock};
191
192    const TTL_MS: u64 = PROMPT_CACHE_TTL_SECS * 1000;
193    const LEAD_MS: u64 = 2_000;
194    const USED_AT: u64 = 1_000_000;
195
196    /// Counts what reaches the provider, and keeps each request it saw.
197    #[derive(Default)]
198    struct Provider {
199        seen: Vec<ModelRequest<'static>>,
200    }
201
202    impl Model for Provider {
203        fn call(&mut self, req: &ModelRequest) -> Result<ModelReturn, AxError> {
204            self.seen.push(req.clone().into_owned());
205            Ok(ModelReturn::bare(
206                kernel::model::message_payload(&[ContentBlock::Text {
207                    text: "warm".to_owned(),
208                }])
209                .unwrap(),
210                Vec::new(),
211            ))
212        }
213    }
214
215    fn request() -> ModelRequest<'static> {
216        ModelRequest {
217            policy: BuildingPolicy::default(),
218            segments: [B3Hash::digest(b"prefix"); 4],
219            chat: ChatRequest::empty("script", Ceiling::new(512).unwrap()),
220        }
221    }
222
223    /// Asks `warmth` for renewals at every instant a timer could wake,
224    /// from the real use to well past one cache lifetime.
225    fn wake_through_two_lifetimes(warmth: &mut Warmth, provider: &mut Provider) {
226        for now in (USED_AT..=USED_AT + 2 * TTL_MS).step_by(1_000) {
227            warmth.renew_due(provider, now).unwrap();
228        }
229    }
230
231    #[test]
232    fn the_default_setting_sends_no_request_of_its_own() {
233        let mut warmth = Warmth::new(KeepWarm::default(), LEAD_MS);
234        let mut provider = Provider::default();
235        warmth.sent(&request(), USED_AT);
236        assert_eq!(warmth.next_due(), None);
237        wake_through_two_lifetimes(&mut warmth, &mut provider);
238        assert!(
239            provider.seen.is_empty(),
240            "{} renewals sent",
241            provider.seen.len()
242        );
243    }
244
245    #[test]
246    fn five_minute_resends_the_last_request_once_with_one_output_token() {
247        let mut warmth = Warmth::new(KeepWarm::FiveMinute, LEAD_MS);
248        let mut provider = Provider::default();
249        warmth.sent(&request(), USED_AT);
250        assert_eq!(warmth.next_due(), Some(USED_AT + TTL_MS - LEAD_MS));
251        wake_through_two_lifetimes(&mut warmth, &mut provider);
252        let mut renewal = request();
253        renewal.chat.max_tokens = Ceiling::new(1);
254        assert_eq!(provider.seen, vec![renewal]);
255        assert_eq!(warmth.next_due(), None);
256    }
257
258    /// What reached the provider behind a [`Warmed`] door, read after
259    /// the door owns the provider.
260    type Seen = std::sync::Arc<std::sync::Mutex<Vec<ModelRequest<'static>>>>;
261
262    struct Behind(Seen);
263
264    impl Model for Behind {
265        fn call(&mut self, req: &ModelRequest) -> Result<ModelReturn, AxError> {
266            let mut provider = Provider::default();
267            let answer = provider.call(req);
268            self.0.lock().unwrap().push(req.clone().into_owned());
269            answer
270        }
271    }
272
273    fn door(setting: KeepWarm) -> (Warmed<impl FnMut() -> Result<TimeMs, AxError>>, Seen) {
274        let seen = Seen::default();
275        let clock = || Ok(TimeMs::new(USED_AT));
276        let warmed = Warmed::new(Box::new(Behind(seen.clone())), setting, clock);
277        (warmed, seen)
278    }
279
280    fn wake_door_through_two_lifetimes(
281        warmed: &mut Warmed<impl FnMut() -> Result<TimeMs, AxError>>,
282    ) {
283        for now in (USED_AT..=USED_AT + 2 * TTL_MS).step_by(1_000) {
284            warmed.renew_due(now).unwrap();
285        }
286    }
287
288    #[test]
289    fn the_default_door_forwards_a_run_and_sends_nothing_of_its_own() {
290        let (mut warmed, seen) = door(KeepWarm::default());
291        warmed.call(&request()).unwrap();
292        wake_door_through_two_lifetimes(&mut warmed);
293        assert_eq!(*seen.lock().unwrap(), vec![request()]);
294    }
295
296    #[test]
297    fn a_five_minute_door_renews_what_a_run_sent_through_it_once() {
298        let (mut warmed, seen) = door(KeepWarm::FiveMinute);
299        warmed.call_streaming(&request(), &mut |_| {}).unwrap();
300        assert_eq!(warmed.next_due(), Some(USED_AT + TTL_MS));
301        wake_door_through_two_lifetimes(&mut warmed);
302        let mut renewal = request();
303        renewal.chat.max_tokens = Ceiling::new(1);
304        assert_eq!(*seen.lock().unwrap(), vec![request(), renewal]);
305    }
306}