Skip to main content

adk_core/
schema_cache.rs

1//! Schema normalization cache for LLM provider adapters.
2//!
3//! Binds one adapter to each cache and stores normalized schemas by content hash,
4//! avoiding redundant normalization without allowing one adapter's result to be
5//! returned for another adapter. The hash ignores the order object keys were
6//! written in, so when `serde_json/preserve_order` is enabled — where insertion
7//! order would otherwise change the key — one schema built two different ways is
8//! one entry.
9//!
10//! # Example
11//!
12//! ```rust
13//! use adk_core::{GenericSchemaAdapter, SchemaCache};
14//! use serde_json::json;
15//! use std::sync::Arc;
16//!
17//! let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
18//! let schema = json!({"type": "object", "properties": {"name": {"type": "string"}}});
19//!
20//! // First call normalizes and caches
21//! let result1 = cache.normalize(&schema);
22//!
23//! // Second call returns cached value without re-normalizing
24//! let result2 = cache.normalize(&schema);
25//! assert_eq!(result1, result2);
26//! ```
27
28use std::collections::HashMap;
29use std::hash::{DefaultHasher, Hash, Hasher};
30use std::sync::{Arc, Mutex};
31
32use serde_json::Value;
33
34use crate::{GenericSchemaAdapter, SchemaAdapter};
35
36/// A thread-safe cache for normalized JSON Schemas.
37///
38/// An adapter-bound cache stores normalized schemas keyed by a 64-bit hash of the
39/// input schema. Binding the adapter at construction prevents results produced by
40/// different adapter instances from sharing an entry.
41///
42/// # Thread Safety
43///
44/// Uses [`std::sync::Mutex`] internally, making it safe to share across threads.
45/// The lock is held only briefly during hash lookup and insertion.
46///
47/// # Placement
48///
49/// Intended to live on model instances so each provider adapter maintains its own
50/// cache of normalized schemas. Use [`SchemaCache::new`] for the generic adapter
51/// or [`SchemaCache::for_adapter`] for a provider-specific adapter.
52#[derive(Debug)]
53pub struct SchemaCache {
54    adapter: Arc<dyn SchemaAdapter>,
55    entries: Mutex<HashMap<CacheKey, Value>>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59enum CacheKey {
60    Bound(u64),
61    Legacy { schema: u64, normalized: u64 },
62}
63
64/// Adds a value to the hash in a fixed order.
65///
66/// - **Object keys are sorted first.** The order they were written in cannot
67///   change the hash.
68/// - **No JSON values are cloned.** Object members are collected into a
69///   temporary `Vec` for sorting; this runs on every lookup, including hits.
70/// - **Each kind of value gets its own marker.** The text `"1"` and the number
71///   `1` hash differently.
72fn hash_canonical(value: &Value, hasher: &mut DefaultHasher) {
73    match value {
74        Value::Null => 0u8.hash(hasher),
75        Value::Bool(flag) => {
76            1u8.hash(hasher);
77            flag.hash(hasher);
78        }
79        Value::Number(number) => {
80            2u8.hash(hasher);
81            // The textual form, because it is exact in every build. Going
82            // through `as_f64` loses precision when `serde_json` is built with
83            // `arbitrary_precision`, where a literal wider than f64 is kept
84            // verbatim: distinct numbers would round together and share a cache
85            // entry, and a literal outside f64 entirely would hash to nothing.
86            number.to_string().hash(hasher);
87        }
88        Value::String(text) => {
89            3u8.hash(hasher);
90            text.hash(hasher);
91        }
92        Value::Array(items) => {
93            4u8.hash(hasher);
94            items.len().hash(hasher);
95            for item in items {
96                hash_canonical(item, hasher);
97            }
98        }
99        Value::Object(members) => {
100            5u8.hash(hasher);
101            members.len().hash(hasher);
102            let mut entries: Vec<(&String, &Value)> = members.iter().collect();
103            entries.sort_unstable_by_key(|(key, _)| *key);
104            for (key, member) in entries {
105                key.hash(hasher);
106                hash_canonical(member, hasher);
107            }
108        }
109    }
110}
111
112impl SchemaCache {
113    /// Creates an empty cache bound to [`GenericSchemaAdapter`].
114    ///
115    /// Use [`SchemaCache::for_adapter`] when normalization requires a
116    /// provider-specific adapter.
117    ///
118    /// # Example
119    ///
120    /// ```rust
121    /// use adk_core::SchemaCache;
122    /// use serde_json::json;
123    ///
124    /// let cache = SchemaCache::new();
125    /// let normalized = cache.normalize(&json!({"type": "string"}));
126    /// ```
127    pub fn new() -> Self {
128        Self::for_adapter(Arc::new(GenericSchemaAdapter))
129    }
130
131    /// Creates an empty cache bound to one schema adapter.
132    ///
133    /// The cache owns the adapter, so every entry is guaranteed to have been
134    /// produced by that adapter instance.
135    ///
136    /// # Example
137    ///
138    /// ```rust
139    /// use adk_core::{GenericSchemaAdapter, SchemaCache};
140    /// use std::sync::Arc;
141    ///
142    /// let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
143    /// assert!(cache.is_empty());
144    /// ```
145    pub fn for_adapter(adapter: Arc<dyn SchemaAdapter>) -> Self {
146        Self { adapter, entries: Mutex::new(HashMap::new()) }
147    }
148
149    /// Returns the schema normalized by this cache's adapter.
150    ///
151    /// If the same input schema has been normalized before, the cached result is
152    /// returned. Otherwise, the bound adapter normalizes the schema and the
153    /// result is stored.
154    ///
155    /// # Example
156    ///
157    /// ```rust
158    /// use adk_core::{GenericSchemaAdapter, SchemaCache};
159    /// use serde_json::json;
160    /// use std::sync::Arc;
161    ///
162    /// let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
163    /// let schema = json!({"$schema": "draft-07", "type": "string"});
164    ///
165    /// let normalized = cache.normalize(&schema);
166    /// assert!(normalized.get("$schema").is_none());
167    /// ```
168    pub fn normalize(&self, schema: &Value) -> Value {
169        let hash = Self::hash_schema(schema);
170        let mut cache = self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
171        cache
172            .entry(CacheKey::Bound(hash))
173            .or_insert_with(|| self.adapter.normalize_schema(schema.clone()))
174            .clone()
175    }
176
177    /// Returns a schema normalized by the supplied adapter.
178    ///
179    /// This compatibility path normalizes before looking up the result because a
180    /// borrowed trait object has no stable identity that the cache can safely
181    /// retain. Use [`SchemaCache::for_adapter`] and [`SchemaCache::normalize`] to
182    /// avoid repeated normalization.
183    #[deprecated(
184        note = "bind the adapter with SchemaCache::for_adapter and call SchemaCache::normalize"
185    )]
186    pub fn get_or_normalize(&self, schema: &Value, adapter: &dyn SchemaAdapter) -> Value {
187        let schema_hash = Self::hash_schema(schema);
188        let normalized = adapter.normalize_schema(schema.clone());
189        let key =
190            CacheKey::Legacy { schema: schema_hash, normalized: Self::hash_schema(&normalized) };
191        let mut cache = self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
192        cache.entry(key).or_insert(normalized).clone()
193    }
194
195    /// Clears all cached entries.
196    ///
197    /// Call this when the set of tools changes (e.g., MCP server advertises
198    /// updated schemas) to force re-normalization on the next request.
199    ///
200    /// # Example
201    ///
202    /// ```rust
203    /// use adk_core::{GenericSchemaAdapter, SchemaCache};
204    /// use serde_json::json;
205    /// use std::sync::Arc;
206    ///
207    /// let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
208    /// let schema = json!({"type": "string"});
209    ///
210    /// // Populate cache
211    /// cache.normalize(&schema);
212    ///
213    /// // Invalidate all entries
214    /// cache.clear();
215    /// ```
216    pub fn clear(&self) {
217        let mut cache = self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
218        cache.clear();
219    }
220
221    /// Returns the number of cached entries.
222    pub fn len(&self) -> usize {
223        let cache = self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
224        cache.len()
225    }
226
227    /// Returns `true` if the cache contains no entries.
228    pub fn is_empty(&self) -> bool {
229        self.len() == 0
230    }
231
232    /// Computes a 64-bit hash of the schema's contents.
233    ///
234    /// Object keys are sorted before hashing, so the order they were written in
235    /// does not change the result. When `serde_json/preserve_order` is enabled,
236    /// keys stay in insertion order, and without sorting the same schema built
237    /// two different ways would occupy two entries and be normalized twice.
238    ///
239    /// Numbers are hashed as written: `5` and `5.0` are separate entries. That
240    /// costs an extra normalization and never returns the wrong schema.
241    fn hash_schema(schema: &Value) -> u64 {
242        let mut hasher = DefaultHasher::new();
243        hash_canonical(schema, &mut hasher);
244        hasher.finish()
245    }
246}
247
248impl Default for SchemaCache {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use serde_json::json;
258    use std::sync::atomic::{AtomicUsize, Ordering};
259
260    use crate::GenericSchemaAdapter;
261
262    fn generic_cache() -> SchemaCache {
263        SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter))
264    }
265
266    #[derive(Debug)]
267    struct TaggedAdapter(&'static str);
268
269    impl SchemaAdapter for TaggedAdapter {
270        fn normalize_schema(&self, mut schema: Value) -> Value {
271            schema
272                .as_object_mut()
273                .expect("test schema should be an object")
274                .insert("normalized_by".to_string(), Value::String(self.0.to_string()));
275            schema
276        }
277    }
278
279    #[derive(Debug)]
280    struct CountingAdapter(Arc<AtomicUsize>);
281
282    impl SchemaAdapter for CountingAdapter {
283        fn normalize_schema(&self, schema: Value) -> Value {
284            self.0.fetch_add(1, Ordering::Relaxed);
285            schema
286        }
287    }
288
289    #[test]
290    fn test_cache_returns_normalized_schema() {
291        let cache = generic_cache();
292        let schema = json!({
293            "$schema": "http://json-schema.org/draft-07/schema#",
294            "type": "object",
295            "properties": { "name": { "type": "string" } }
296        });
297
298        let result = cache.normalize(&schema);
299        assert!(result.get("$schema").is_none());
300        assert_eq!(result["type"], "object");
301    }
302
303    #[test]
304    fn test_cache_returns_same_result_on_repeated_calls() {
305        let cache = generic_cache();
306        let schema = json!({
307            "type": "object",
308            "properties": { "x": { "type": "integer", "const": 42 } }
309        });
310
311        let first = cache.normalize(&schema);
312        let second = cache.normalize(&schema);
313        assert_eq!(first, second);
314    }
315
316    #[test]
317    fn repeated_calls_only_invoke_the_bound_adapter_once() {
318        let calls = Arc::new(AtomicUsize::new(0));
319        let cache = SchemaCache::for_adapter(Arc::new(CountingAdapter(Arc::clone(&calls))));
320        let schema = json!({"type": "string"});
321
322        cache.normalize(&schema);
323        cache.normalize(&schema);
324
325        assert_eq!(calls.load(Ordering::Relaxed), 1);
326    }
327
328    #[test]
329    fn adapter_instances_cannot_share_entries() {
330        let schema = json!({"type": "object"});
331        let alpha = SchemaCache::for_adapter(Arc::new(TaggedAdapter("alpha")));
332        let beta = SchemaCache::for_adapter(Arc::new(TaggedAdapter("beta")));
333
334        assert_eq!(alpha.normalize(&schema)["normalized_by"], "alpha");
335        assert_eq!(beta.normalize(&schema)["normalized_by"], "beta");
336        assert_eq!(alpha.len(), 1);
337        assert_eq!(beta.len(), 1);
338    }
339
340    #[test]
341    #[allow(deprecated)]
342    fn deprecated_api_keeps_adapter_results_separate() {
343        let cache = SchemaCache::new();
344        let schema = json!({"type": "object"});
345
346        let alpha = cache.get_or_normalize(&schema, &TaggedAdapter("alpha"));
347        let beta = cache.get_or_normalize(&schema, &TaggedAdapter("beta"));
348
349        assert_eq!(alpha["normalized_by"], "alpha");
350        assert_eq!(beta["normalized_by"], "beta");
351        assert_eq!(cache.len(), 2);
352    }
353
354    #[test]
355    fn test_cache_stores_entries() {
356        let cache = generic_cache();
357
358        assert!(cache.is_empty());
359        assert_eq!(cache.len(), 0);
360
361        let schema1 = json!({"type": "string"});
362        let schema2 = json!({"type": "number"});
363
364        cache.normalize(&schema1);
365        assert_eq!(cache.len(), 1);
366
367        cache.normalize(&schema2);
368        assert_eq!(cache.len(), 2);
369
370        // Same schema doesn't add a new entry
371        cache.normalize(&schema1);
372        assert_eq!(cache.len(), 2);
373    }
374
375    #[test]
376    fn test_cache_clear_removes_all_entries() {
377        let cache = generic_cache();
378
379        cache.normalize(&json!({"type": "string"}));
380        cache.normalize(&json!({"type": "number"}));
381        assert_eq!(cache.len(), 2);
382
383        cache.clear();
384        assert!(cache.is_empty());
385    }
386
387    #[test]
388    fn test_cache_different_schemas_produce_different_entries() {
389        let cache = generic_cache();
390
391        let schema_a = json!({"type": "string", "format": "hostname"});
392        let schema_b = json!({"type": "string", "format": "email"});
393
394        let result_a = cache.normalize(&schema_a);
395        let result_b = cache.normalize(&schema_b);
396
397        // "hostname" is stripped, "email" is preserved
398        assert!(result_a.get("format").is_none());
399        assert_eq!(result_b["format"], "email");
400        assert_eq!(cache.len(), 2);
401    }
402
403    #[test]
404    fn test_cache_new_is_empty() {
405        let cache = SchemaCache::new();
406        assert!(cache.is_empty());
407        assert_eq!(cache.len(), 0);
408    }
409
410    #[test]
411    fn test_cache_default_is_empty() {
412        let cache = SchemaCache::default();
413        assert!(cache.is_empty());
414    }
415
416    #[test]
417    fn test_cache_handles_empty_schema() {
418        let cache = generic_cache();
419        let schema = json!({});
420
421        let result = cache.normalize(&schema);
422        assert_eq!(result, json!({}));
423        assert_eq!(cache.len(), 1);
424    }
425
426    #[test]
427    fn test_cache_handles_null_schema() {
428        let cache = generic_cache();
429        let schema = Value::Null;
430
431        let result = cache.normalize(&schema);
432        // GenericSchemaAdapter passes through non-object values
433        assert_eq!(result, Value::Null);
434        assert_eq!(cache.len(), 1);
435    }
436
437    /// Two schemas differing only in key order are one schema, so they share a
438    /// cache entry rather than each paying for normalization.
439    #[test]
440    fn key_order_does_not_create_a_second_entry() {
441        let cache = generic_cache();
442
443        cache.normalize(&json!({ "type": "object", "properties": { "a": {}, "b": {} } }));
444        cache.normalize(&json!({ "properties": { "b": {}, "a": {} }, "type": "object" }));
445
446        assert_eq!(cache.len(), 1, "key order must not change a schema's identity");
447    }
448
449    #[test]
450    fn genuinely_different_schemas_keep_separate_entries() {
451        let cache = generic_cache();
452
453        cache.normalize(&json!({ "type": "string" }));
454        cache.normalize(&json!({ "type": "integer" }));
455
456        assert_eq!(cache.len(), 2);
457    }
458
459    /// A property name containing pointer or escape syntax must not collapse
460    /// two schemas onto one key.
461    #[test]
462    fn unusual_property_names_stay_distinct() {
463        let cache = generic_cache();
464
465        cache.normalize(&json!({ "properties": { "a/b": {} } }));
466        cache.normalize(&json!({ "properties": { "a~b": {} } }));
467
468        assert_eq!(cache.len(), 2);
469    }
470
471    /// Text and a number that render alike must not collide.
472    #[test]
473    fn a_string_and_a_number_hash_differently() {
474        let cache = generic_cache();
475
476        cache.normalize(&json!({ "const": "1" }));
477        cache.normalize(&json!({ "const": 1 }));
478
479        assert_eq!(cache.len(), 2);
480    }
481
482    /// Numbers are identified by their written form, so `5` and `5.0` are
483    /// different schemas.
484    ///
485    /// This also closes an `arbitrary_precision` hazard that cannot be
486    /// exercised here: with that feature a literal wider than f64 is kept
487    /// verbatim, and identifying numbers by `as_f64` would round distinct
488    /// values onto one cache entry. Without the feature `serde_json` already
489    /// collapses such literals during parsing, so reproducing it would mean
490    /// enabling the feature for every crate in the build.
491    #[test]
492    fn numbers_are_identified_by_their_written_form() {
493        let cache = generic_cache();
494
495        cache.normalize(&json!({ "const": 5 }));
496        cache.normalize(&json!({ "const": 5.0 }));
497
498        assert_eq!(cache.len(), 2);
499    }
500}