Skip to main content

sim_lib_openai_server/runtime/
cache.rs

1use std::collections::BTreeMap;
2
3use sim_kernel::{ContentId, Cx, Error, Expr, Result};
4
5use crate::{
6    capabilities::ai_runner_cache_capability,
7    content_id::{content_id_for_expr, request_expr_content_id},
8    objects::content_id_expr,
9};
10
11/// Identifies a cached plan evaluation by the content ids of its request and plan.
12///
13/// Two evaluations collide in the plan cache only when both the request and the
14/// plan hash to the same [`ContentId`], so the same plan run against different
15/// requests (or different plans for the same request) stay distinct entries.
16#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
17pub struct PlanCacheKey {
18    request_content_id: ContentId,
19    plan_content_id: ContentId,
20}
21
22impl PlanCacheKey {
23    /// Returns a key built directly from precomputed request and plan content ids.
24    pub fn new(request_content_id: ContentId, plan_content_id: ContentId) -> Self {
25        Self {
26            request_content_id,
27            plan_content_id,
28        }
29    }
30
31    /// Returns the key derived by hashing the request and plan expressions.
32    pub fn for_request_plan(request: &Expr, plan: &Expr) -> Result<Self> {
33        Ok(Self::new(
34            request_expr_content_id(request)?,
35            content_id_for_expr(plan)?,
36        ))
37    }
38
39    /// Returns the content id of the request portion of the key.
40    pub fn request_content_id(&self) -> &ContentId {
41        &self.request_content_id
42    }
43
44    /// Returns the content id of the plan portion of the key.
45    pub fn plan_content_id(&self) -> &ContentId {
46        &self.plan_content_id
47    }
48
49    /// Returns the key as a map expression with request and plan content ids.
50    pub fn to_expr(&self) -> Expr {
51        Expr::Map(vec![
52            field(
53                "request-content-id",
54                content_id_expr(&self.request_content_id),
55            ),
56            field("plan-content-id", content_id_expr(&self.plan_content_id)),
57        ])
58    }
59}
60
61/// Controls whether a plan evaluation reads from and/or writes to the plan cache.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub enum PlanCacheMode {
64    /// Bypasses the cache entirely: no reads and no writes.
65    Disabled,
66    /// Reads cached responses and writes fresh ones back (the default).
67    #[default]
68    ReadThrough,
69    /// Reads cached responses but never writes new entries.
70    ReadOnly,
71    /// Writes fresh responses but never serves cached ones.
72    WriteOnly,
73    /// Ignores existing entries on read yet writes the recomputed response.
74    Refresh,
75}
76
77impl PlanCacheMode {
78    /// Parses a cache mode from a symbol, string, or `plan/atom` expression.
79    pub fn from_expr(expr: &Expr) -> Result<Self> {
80        match expr {
81            Expr::String(name) => Self::from_name(name),
82            Expr::Symbol(symbol) if symbol.namespace.is_none() => Self::from_name(&symbol.name),
83            Expr::List(items) => match items.as_slice() {
84                [Expr::Symbol(head), Expr::String(name)]
85                    if head.namespace.is_none() && head.name.as_ref() == "plan/atom" =>
86                {
87                    Self::from_name(name)
88                }
89                _ => Err(Error::Eval(
90                    "plan/cache mode must be a symbol, string, or atom".to_owned(),
91                )),
92            },
93            _ => Err(Error::Eval(
94                "plan/cache mode must be a symbol, string, or atom".to_owned(),
95            )),
96        }
97    }
98
99    /// Parses a cache mode from its textual name (e.g. `"read-through"`).
100    pub fn from_name(name: &str) -> Result<Self> {
101        match name {
102            "off" | "none" | "disabled" => Ok(Self::Disabled),
103            "read-through" | "read-write" | "default" => Ok(Self::ReadThrough),
104            "read" | "read-only" => Ok(Self::ReadOnly),
105            "write" | "write-only" => Ok(Self::WriteOnly),
106            "refresh" => Ok(Self::Refresh),
107            other => Err(Error::Eval(format!("unsupported plan/cache mode {other}"))),
108        }
109    }
110
111    /// Returns `true` when the mode serves cached responses.
112    pub fn reads(self) -> bool {
113        matches!(self, Self::ReadThrough | Self::ReadOnly)
114    }
115
116    /// Returns `true` when the mode stores recomputed responses.
117    pub fn writes(self) -> bool {
118        matches!(self, Self::ReadThrough | Self::WriteOnly | Self::Refresh)
119    }
120}
121
122/// Selects where a cache write lands and which capability it requires.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum PlanCacheWriteTarget {
125    /// Stores the entry in process memory only; requires no capability.
126    Memory,
127    /// Persists the entry beyond the process; requires the runner cache capability.
128    Persistent,
129}
130
131/// In-memory store of plan evaluation responses keyed by [`PlanCacheKey`].
132#[derive(Clone, Debug, Default)]
133pub struct OpenAiPlanCache {
134    entries: BTreeMap<PlanCacheKey, Expr>,
135}
136
137impl OpenAiPlanCache {
138    /// Returns an empty plan cache.
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    /// Returns the cached response for `key`, or `None` when absent.
144    pub fn get(&self, key: &PlanCacheKey) -> Option<&Expr> {
145        self.entries.get(key)
146    }
147
148    /// Inserts a response under `key`, checking the cache capability when persistent.
149    ///
150    /// Writes to [`PlanCacheWriteTarget::Persistent`] require the runner cache
151    /// capability and fail closed if it is not granted; memory writes always
152    /// succeed.
153    pub fn put(
154        &mut self,
155        cx: &mut Cx,
156        target: PlanCacheWriteTarget,
157        key: PlanCacheKey,
158        response: Expr,
159    ) -> Result<()> {
160        if target == PlanCacheWriteTarget::Persistent {
161            cx.require(&ai_runner_cache_capability())?;
162        }
163        self.entries.insert(key, response);
164        Ok(())
165    }
166
167    /// Returns the number of cached entries.
168    pub fn len(&self) -> usize {
169        self.entries.len()
170    }
171
172    /// Returns `true` when the cache holds no entries.
173    pub fn is_empty(&self) -> bool {
174        self.entries.is_empty()
175    }
176}
177
178use sim_value::build::entry as field;
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn cache_modes_report_read_and_write_behavior() {
186        let cases = [
187            ("disabled", false, false),
188            ("read-through", true, true),
189            ("read-only", true, false),
190            ("write-only", false, true),
191            ("refresh", false, true),
192        ];
193
194        for (name, reads, writes) in cases {
195            let mode = PlanCacheMode::from_name(name).unwrap();
196            assert_eq!(mode.reads(), reads, "{name} read behavior");
197            assert_eq!(mode.writes(), writes, "{name} write behavior");
198        }
199    }
200}