Skip to main content

json_eval_rs/jsoneval/
eval_cache.rs

1use indexmap::IndexSet;
2use serde_json::Value;
3use std::collections::{HashMap, HashSet};
4
5/// Token-version tracker for json paths
6#[derive(Default, Clone)]
7pub struct VersionTracker {
8    versions: HashMap<String, u64>,
9}
10
11impl VersionTracker {
12    pub fn new() -> Self {
13        Self {
14            versions: HashMap::new(),
15        }
16    }
17
18    #[inline]
19    pub fn get(&self, path: &str) -> u64 {
20        self.versions.get(path).copied().unwrap_or(0)
21    }
22
23    #[inline]
24    pub fn bump(&mut self, path: &str) {
25        let current = self.get(path);
26        // We use actual data pointers here
27        self.versions.insert(path.to_string(), current + 1);
28    }
29
30    /// Merge version counters from `other`, taking the **maximum** for each path.
31    /// Using max (not insert) ensures that if this tracker already saw a higher version
32    /// for a path (e.g., from a previous subform evaluation round), it is never downgraded.
33    pub fn merge_from(&mut self, other: &VersionTracker) {
34        for (k, v) in &other.versions {
35            let current = self.versions.get(k).copied().unwrap_or(0);
36            self.versions.insert(k.clone(), current.max(*v));
37        }
38    }
39
40    /// Merge only `/$params`-prefixed version counters from `other` (max strategy).
41    /// Used when giving a per-item tracker the latest schema-level param versions
42    /// without absorbing data-path bumps that belong to other items.
43    pub fn merge_from_params(&mut self, other: &VersionTracker) {
44        for (k, v) in &other.versions {
45            if k.starts_with("/$params") {
46                let current = self.versions.get(k).copied().unwrap_or(0);
47                self.versions.insert(k.clone(), current.max(*v));
48            }
49        }
50    }
51
52    /// Returns true if any tracked path with the given prefix has been bumped (version > 0).
53    /// Used to gate table re-evaluation when item fields change without the item being new.
54    pub fn any_bumped_with_prefix(&self, prefix: &str) -> bool {
55        self.versions
56            .iter()
57            .any(|(k, &v)| k.starts_with(prefix) && v > 0)
58    }
59
60    /// Returns true if any path with the given prefix has a **higher** version than in `baseline`.
61    /// Unlike `any_bumped_with_prefix`, this detects only brand-new bumps from a specific diff
62    /// pass, ignoring historical bumps that were already present in the baseline.
63    pub fn any_newly_bumped_with_prefix(&self, prefix: &str, baseline: &VersionTracker) -> bool {
64        self.versions
65            .iter()
66            .any(|(k, &v)| k.starts_with(prefix) && v > baseline.get(k))
67    }
68
69    /// Returns an iterator over all (path, version) pairs, for targeted bump enumeration.
70    pub fn versions(&self) -> impl Iterator<Item = (&str, &u64)> {
71        self.versions.iter().map(|(k, v)| (k.as_str(), v))
72    }
73}
74
75/// A cached evaluation result with the specific dependency versions it was evaluated against
76#[derive(Clone)]
77pub struct CacheEntry {
78    pub dep_versions: HashMap<String, u64>,
79    pub result: Value,
80    /// The `active_item_index` this entry was computed under.
81    /// `None` = computed during main-form evaluation (safe to reuse across all items
82    /// provided the dep versions match). `Some(idx)` = computed for a specific item;
83    /// Tier-2 reuse is restricted to entries whose deps are entirely `$params`-scoped.
84    pub computed_for_item: Option<usize>,
85}
86
87/// Independent cache state for a single item in a subform array
88#[derive(Default, Clone)]
89pub struct SubformItemCache {
90    pub data_versions: VersionTracker,
91    pub entries: HashMap<String, CacheEntry>,
92    pub item_snapshot: Value,
93    /// Per-item snapshot of the evaluated schema captured after each evaluate_subform_item.
94    /// Allows get_evaluated_schema_subform to return the correct per-item values without
95    /// re-running the full evaluation pipeline in a shared subform context.
96    pub evaluated_schema: Option<Value>,
97}
98
99impl SubformItemCache {
100    pub fn new() -> Self {
101        Self {
102            data_versions: VersionTracker::new(),
103            entries: HashMap::new(),
104            item_snapshot: Value::Null,
105            evaluated_schema: None,
106        }
107    }
108}
109
110/// Primary cache structure for a JSON evaluation instance
111#[derive(Clone)]
112pub struct EvalCache {
113    pub data_versions: VersionTracker,
114    pub params_versions: VersionTracker,
115    pub entries: HashMap<String, CacheEntry>,
116
117    pub active_item_index: Option<usize>,
118    pub subform_caches: HashMap<usize, SubformItemCache>,
119
120    /// Monotonically increasing counter bumped whenever data_versions or params_versions change.
121    /// When `eval_generation == last_evaluated_generation`, all cache entries are guaranteed valid
122    /// and `evaluate_internal` can skip the full tree traversal.
123    pub eval_generation: u64,
124    pub last_evaluated_generation: u64,
125
126    /// Snapshot of the last fully-diffed main-form data payload.
127    /// Stored after each successful `evaluate_internal_with_new_data` call so the next
128    /// invocation can avoid an extra `snapshot_data_clone()` when computing the diff.
129    pub main_form_snapshot: Option<Value>,
130}
131
132impl Default for EvalCache {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl EvalCache {
139    pub fn new() -> Self {
140        Self {
141            data_versions: VersionTracker::new(),
142            params_versions: VersionTracker::new(),
143            entries: HashMap::new(),
144            active_item_index: None,
145            subform_caches: HashMap::new(),
146            eval_generation: 0,
147            last_evaluated_generation: u64::MAX, // force first evaluate_internal to run
148            main_form_snapshot: None,
149        }
150    }
151
152    pub fn clear(&mut self) {
153        self.data_versions = VersionTracker::new();
154        self.params_versions = VersionTracker::new();
155        self.entries.clear();
156        self.active_item_index = None;
157        self.subform_caches.clear();
158        self.eval_generation = 0;
159        self.last_evaluated_generation = u64::MAX;
160        self.main_form_snapshot = None;
161    }
162
163    /// Remove item caches for indices >= `current_count`.
164    /// Call this whenever the subform array length is known to have shrunk so that
165    /// stale per-item version trackers and cached entries do not linger in memory.
166    pub fn prune_subform_caches(&mut self, current_count: usize) {
167        self.subform_caches.retain(|&idx, _| idx < current_count);
168    }
169
170    /// Invalidate all `$params`-scoped table cache entries for a specific item.
171    ///
172    /// Called when a brand-new subform item is introduced so that `$params` tables
173    /// that aggregate array data (e.g. WOP_RIDERS) are forced to recompute instead
174    /// of returning stale results cached from a prior main-form evaluation that ran
175    /// when the item was absent (and thus saw zero/null for that item's values).
176    pub fn invalidate_params_tables_for_item(&mut self, idx: usize, table_keys: &[String]) {
177        // Bump params_versions so T2 global entries for these tables are stale.
178        for key in table_keys {
179            let data_path = crate::jsoneval::path_utils::normalize_to_json_pointer(key)
180                .replace("/properties/", "/");
181            let data_path = data_path.trim_start_matches('#');
182            let data_path = if data_path.starts_with('/') {
183                data_path.to_string()
184            } else {
185                format!("/{}", data_path)
186            };
187            self.params_versions.bump(&data_path);
188            self.eval_generation += 1;
189        }
190
191        // Evict matching T1 (item-level) entries so they are not reused.
192        if let Some(item_cache) = self.subform_caches.get_mut(&idx) {
193            for key in table_keys {
194                item_cache.entries.remove(key);
195            }
196        }
197    }
198
199    /// Returns true if evaluate_internal must run (versions changed since last full evaluation)
200    pub fn needs_full_evaluation(&self) -> bool {
201        self.eval_generation != self.last_evaluated_generation
202    }
203
204    /// Call after evaluate_internal completes successfully to mark the generation stable
205    pub fn mark_evaluated(&mut self) {
206        self.last_evaluated_generation = self.eval_generation;
207    }
208
209    pub(crate) fn ensure_active_item_cache(&mut self, idx: usize) {
210        self.subform_caches
211            .entry(idx)
212            .or_insert_with(SubformItemCache::new);
213    }
214
215    pub fn set_active_item(&mut self, idx: usize) {
216        self.active_item_index = Some(idx);
217        self.ensure_active_item_cache(idx);
218    }
219
220    pub fn clear_active_item(&mut self) {
221        self.active_item_index = None;
222    }
223
224    /// Recursively diffs `old` against `new` and bumps version for every changed data path scalar.
225    pub fn store_snapshot_and_diff_versions(&mut self, old: &Value, new: &Value) {
226        if let Some(idx) = self.active_item_index {
227            self.ensure_active_item_cache(idx);
228            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
229            diff_and_update_versions(&mut sub_cache.data_versions, "", old, new);
230            sub_cache.item_snapshot = new.clone();
231        } else {
232            diff_and_update_versions(&mut self.data_versions, "", old, new);
233        }
234    }
235
236    pub fn get_active_snapshot(&self) -> Value {
237        if let Some(idx) = self.active_item_index {
238            self.subform_caches
239                .get(&idx)
240                .map(|c| c.item_snapshot.clone())
241                .unwrap_or(Value::Null)
242        } else {
243            Value::Null
244        }
245    }
246
247    pub fn diff_active_item(
248        &mut self,
249        field_key: &str,
250        old_sub_data: &Value,
251        new_sub_data: &Value,
252    ) {
253        if let Some(idx) = self.active_item_index {
254            self.ensure_active_item_cache(idx);
255            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
256
257            // Diff ONLY the localized item part, skipping the massive parent tree
258            let empty = Value::Null;
259            let old_item = old_sub_data.get(field_key).unwrap_or(&empty);
260            let new_item = new_sub_data.get(field_key).unwrap_or(&empty);
261
262            diff_and_update_versions(
263                &mut sub_cache.data_versions,
264                &format!("/{}", field_key),
265                old_item,
266                new_item,
267            );
268            sub_cache.item_snapshot = new_sub_data.clone();
269        }
270    }
271
272    pub fn bump_data_version(&mut self, data_path: &str) {
273        // Always signal that something changed so the parent's needs_full_evaluation()
274        // returns true even when the bump was item-scoped.
275        self.eval_generation += 1;
276        if let Some(idx) = self.active_item_index {
277            if let Some(cache) = self.subform_caches.get_mut(&idx) {
278                cache.data_versions.bump(data_path);
279            }
280        } else {
281            self.data_versions.bump(data_path);
282        }
283    }
284
285    pub fn bump_params_version(&mut self, data_path: &str) {
286        self.params_versions.bump(data_path);
287        self.eval_generation += 1;
288    }
289
290    /// Check if the `eval_key` result can be safely bypassed because dependencies are unchanged.
291    ///
292    /// Two-tier lookup:
293    /// - Tier 1: item-scoped entries in `subform_caches[idx]` — checked first when an active item is set
294    /// - Tier 2: global `self.entries` — allows Run 1 (main form) results to be reused in Run 2 (subform)
295    pub fn check_cache(&self, eval_key: &str, deps: &IndexSet<String>) -> Option<Value> {
296        if let Some(idx) = self.active_item_index {
297            // Tier 1: item-specific entries (always safe to reuse for the same index)
298            if let Some(cache) = self.subform_caches.get(&idx) {
299                if let Some(hit) =
300                    self.validate_entry(eval_key, deps, &cache.entries, &cache.data_versions)
301                {
302                    if crate::utils::is_debug_cache_enabled() {
303                        println!("Cache HIT [T1 idx={}] {}", idx, eval_key);
304                    }
305                    return Some(hit);
306                }
307            }
308
309            // Tier 2: global entries (may have been stored by main-form Run 1).
310            // Only reuse if the entry is index-safe:
311            //   (a) computed with no active item (main-form result), OR
312            //   (b) computed for the same item index, OR
313            //   (c) all deps are $params-scoped (truly index-independent)
314            let item_data_versions = self
315                .subform_caches
316                .get(&idx)
317                .map(|c| &c.data_versions)
318                .unwrap_or(&self.data_versions);
319
320            if let Some(entry) = self.entries.get(eval_key) {
321                let index_safe = match entry.computed_for_item {
322                    // Main-form entry (no active item when stored): only safe if ALL its deps
323                    // are $params-scoped. Non-$params deps (like /riders/prem_pay_period) mean
324                    // the formula result is rider-specific — using it for a different rider via
325                    // the batch fast path would corrupt eval_data and poison subsequent formulas.
326                    None => entry.dep_versions.keys().all(|p| p.starts_with("/$params")),
327                    Some(stored_idx) if stored_idx == idx => true,
328                    _ => entry.dep_versions.keys().all(|p| p.starts_with("/$params")),
329                };
330                if index_safe {
331                    let result =
332                        self.validate_entry(eval_key, deps, &self.entries, item_data_versions);
333                    if result.is_some() {
334                        if crate::utils::is_debug_cache_enabled() {
335                            println!(
336                                "Cache HIT [T2 idx={} for={:?}] {}",
337                                idx, entry.computed_for_item, eval_key
338                            );
339                        }
340                    }
341                    return result;
342                }
343            }
344
345            None
346        } else {
347            self.validate_entry(eval_key, deps, &self.entries, &self.data_versions)
348        }
349    }
350
351    /// Specialized cache check for `$params`-scoped table evaluations.
352    ///
353    /// Tables in `$params/references/` aggregate cross-item data and produce a single result
354    /// that is independent of which subform item is currently active. The standard `check_cache`
355    /// blocks T2 reuse for entries whose deps include non-`$params` paths (e.g. `/riders/...`),
356    /// because scalar formula results are item-specific. But table results are global: the same
357    /// 734-row array is correct for rider 0, rider 1, and rider 2 alike.
358    ///
359    /// This method validates the global entry directly — using `item_data_versions` for
360    /// non-`$params` deps — without the `index_safe` gate, allowing the expensive table forward/
361    /// backward pass to be skipped when inputs have not changed.
362    pub fn check_table_cache(&self, eval_key: &str, deps: &IndexSet<String>) -> Option<Value> {
363        if let Some(idx) = self.active_item_index {
364            // Tier 1: item-scoped entries first (unlikely for $params tables but check anyway)
365            if let Some(cache) = self.subform_caches.get(&idx) {
366                if let Some(hit) =
367                    self.validate_entry(eval_key, deps, &cache.entries, &cache.data_versions)
368                {
369                    if crate::utils::is_debug_cache_enabled() {
370                        println!("Cache HIT [T1 table idx={}] {}", idx, eval_key);
371                    }
372                    return Some(hit);
373                }
374            }
375
376            // Tier 2: validate the global entry against the parent main-form tracker.
377            //
378            // Global $params table entries are stored using `self.data_versions` (store_cache
379            // with no active item). When a rider field (e.g. `riders.sa`) changes via
380            // `with_item_cache_swap`, the newly-bumped paths are propagated into
381            // `parent_cache.data_versions` before the swap. This ensures the T2 check
382            // here correctly sees the change without needing MaxVersionTracker, which
383            // would pick up historical per-rider bumps and cause false misses.
384            let result = self.validate_entry(eval_key, deps, &self.entries, &self.data_versions);
385            if result.is_some() {
386                if crate::utils::is_debug_cache_enabled() {
387                    println!("Cache HIT [T2 table idx={}] {}", idx, eval_key);
388                }
389            }
390            result
391        } else {
392            self.validate_entry(eval_key, deps, &self.entries, &self.data_versions)
393        }
394    }
395
396    fn validate_entry(
397        &self,
398        eval_key: &str,
399        deps: &IndexSet<String>,
400        entries: &HashMap<String, CacheEntry>,
401        data_versions: &VersionTracker,
402    ) -> Option<Value> {
403        let entry = entries.get(eval_key)?;
404        for dep in deps {
405            let data_dep_path = crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
406                .replace("/properties/", "/");
407
408            let current_ver = if data_dep_path.starts_with("/$params") {
409                self.params_versions.get(&data_dep_path)
410            } else {
411                data_versions.get(&data_dep_path)
412            };
413
414            if let Some(&cached_ver) = entry.dep_versions.get(&data_dep_path) {
415                if current_ver != cached_ver {
416                    if crate::utils::is_debug_cache_enabled() {
417                        println!(
418                            "Cache MISS {}: dep {} changed ({} -> {})",
419                            eval_key, data_dep_path, cached_ver, current_ver
420                        );
421                    }
422                    return None;
423                }
424            } else {
425                if crate::utils::is_debug_cache_enabled() {
426                    println!(
427                        "Cache MISS {}: dep {} missing from cache entry",
428                        eval_key, data_dep_path
429                    );
430                }
431                return None;
432            }
433        }
434        if crate::utils::is_debug_cache_enabled() {
435            println!("Cache HIT {}", eval_key);
436        }
437        Some(entry.result.clone())
438    }
439
440    /// Store the newly evaluated value and snapshot the dependency versions.
441    ///
442    /// Storage strategy:
443    /// - When an active item is set, store into `subform_caches[idx].entries` (item-scoped).
444    ///   This isolates per-rider results so different items with different data don't collide.
445    /// - The global `self.entries` is written only from the main form (no active item).
446    ///   Subforms can reuse these via the Tier 2 fallback in `check_cache`.
447    pub fn store_cache(&mut self, eval_key: &str, deps: &IndexSet<String>, result: Value) {
448        // Phase 1: snapshot dep versions using the correct data_versions tracker.
449        // Always use item data_versions for T1; for T2 promotion of $params tables we
450        // build a separate snapshot using PARENT data_versions (see Phase 2 note below).
451        let mut dep_versions = HashMap::with_capacity(deps.len());
452        {
453            let data_versions = if let Some(idx) = self.active_item_index {
454                self.ensure_active_item_cache(idx);
455                &self.subform_caches[&idx].data_versions
456            } else {
457                &self.data_versions
458            };
459
460            for dep in deps {
461                let data_dep_path = crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
462                    .replace("/properties/", "/");
463                let ver = if data_dep_path.starts_with("/$params") {
464                    self.params_versions.get(&data_dep_path)
465                } else {
466                    data_versions.get(&data_dep_path)
467                };
468                dep_versions.insert(data_dep_path, ver);
469            }
470        }
471
472        // Phase 2: insert into the correct tier, tagging with the current item index.
473        let computed_for_item = self.active_item_index;
474
475        // For $params-scoped entries, only bump params_versions when the result value
476        // actually changed relative to the canonical cached entry.
477        //
478        // For T1 stores (active_item set), we compare against T2 (global) first.
479        // T2 is the authoritative reference: if T2 already holds the same value,
480        // params_versions was already bumped for it — bumping again per-rider causes
481        // an O(riders × $params_formulas) version explosion that makes every downstream
482        // formula (TOTAL_WOP_SA, WOP_MULTIPLIER, COMMISSION_FACTOR…) miss on each rider.
483        if eval_key.starts_with("#/$params") {
484            let existing_result: Option<&Value> = if let Some(idx) = self.active_item_index {
485                // Check T2 (global) first — if T2 has same value, no need to bump again.
486                self.entries.get(eval_key).map(|e| &e.result).or_else(|| {
487                    self.subform_caches
488                        .get(&idx)
489                        .and_then(|c| c.entries.get(eval_key))
490                        .map(|e| &e.result)
491                })
492            } else {
493                self.entries.get(eval_key).map(|e| &e.result)
494            };
495
496            let value_changed = existing_result.map_or(true, |r| r != &result);
497
498            if value_changed {
499                let data_path = crate::jsoneval::path_utils::normalize_to_json_pointer(eval_key)
500                    .replace("/properties/", "/");
501                let data_path = data_path.trim_start_matches('#').to_string();
502                let data_path = if data_path.starts_with('/') {
503                    data_path
504                } else {
505                    format!("/{}", data_path)
506                };
507
508                // Bump the explicit path and its table-level parent.
509                // Stop at slash_count < 3 — never bump /$params/others or /$params itself.
510                let mut current_path = data_path.as_str();
511                let mut slash_count = current_path.matches('/').count();
512
513                while slash_count >= 3 {
514                    self.params_versions.bump(current_path);
515                    if let Some(last_slash) = current_path.rfind('/') {
516                        current_path = &current_path[..last_slash];
517                        slash_count -= 1;
518                    } else {
519                        break;
520                    }
521                }
522
523                self.eval_generation += 1;
524            }
525        }
526
527        let entry = CacheEntry {
528            dep_versions,
529            result,
530            computed_for_item,
531        };
532
533        if let Some(idx) = self.active_item_index {
534            // Store item-scoped: isolates per-rider entries so riders with different data don't collide
535            self.subform_caches
536                .get_mut(&idx)
537                .unwrap()
538                .entries
539                .insert(eval_key.to_string(), entry.clone());
540
541            // For $params-scoped tables, also promote to T2 (global entries).
542            // CRITICAL: T2 must be validated by check_table_cache using PARENT data_versions,
543            // not item data_versions. If we promoted the item-dep snapshot directly:
544            //   - T2 dep[/riders/code] = item_data_versions[/riders/code] = 1 (bumped for this rider)
545            //   - check_table_cache validates with parent data_versions[/riders/code] = 0
546            //   - 1 ≠ 0 → guaranteed miss for every other rider
547            // Fix: rebuild dep_versions using PARENT data_versions for non-$params paths.
548            // T1 retains item data_versions (correct for per-item scoping).
549            if eval_key.starts_with("#/$params") {
550                let t2_dep_versions: HashMap<String, u64> = entry
551                    .dep_versions
552                    .iter()
553                    .map(|(path, &item_ver)| {
554                        let parent_ver = if path.starts_with("/$params") {
555                            item_ver // params_versions are global — same for both
556                        } else {
557                            // Use parent data_versions, which is what check_table_cache reads
558                            self.data_versions.get(path)
559                        };
560                        (path.clone(), parent_ver)
561                    })
562                    .collect();
563
564                let t2_entry = CacheEntry {
565                    dep_versions: t2_dep_versions,
566                    result: entry.result.clone(),
567                    computed_for_item,
568                };
569                self.entries.insert(eval_key.to_string(), t2_entry);
570            }
571        } else {
572            self.entries.insert(eval_key.to_string(), entry);
573        }
574    }
575}
576
577/// Recursive helper to walk JSON structures and bump specific leaf versions where they differ
578pub(crate) fn diff_and_update_versions(
579    tracker: &mut VersionTracker,
580    pointer: &str,
581    old: &Value,
582    new: &Value,
583) {
584    if pointer.is_empty() {
585        diff_and_update_versions_internal(tracker, "", old, new);
586    } else {
587        diff_and_update_versions_internal(tracker, pointer, old, new);
588    }
589}
590
591fn diff_and_update_versions_internal(
592    tracker: &mut VersionTracker,
593    pointer: &str,
594    old: &Value,
595    new: &Value,
596) {
597    if old == new {
598        return;
599    }
600
601    match (old, new) {
602        (Value::Object(a), Value::Object(b)) => {
603            let mut keys = HashSet::new();
604            for k in a.keys() {
605                keys.insert(k.as_str());
606            }
607            for k in b.keys() {
608                keys.insert(k.as_str());
609            }
610
611            for key in keys {
612                // Do not deep-diff $params at any nesting level — it is manually tracked
613                // via bump_params_version on evaluations. Skipping at root-only was insufficient
614                // when item data is diffed via a non-empty pointer prefix.
615                if key == "$params" {
616                    continue;
617                }
618
619                let a_val = a.get(key).unwrap_or(&Value::Null);
620                let b_val = b.get(key).unwrap_or(&Value::Null);
621
622                let escaped_key = key.replace('~', "~0").replace('/', "~1");
623                let next_path = format!("{}/{}", pointer, escaped_key);
624                diff_and_update_versions_internal(tracker, &next_path, a_val, b_val);
625            }
626        }
627        (Value::Array(a), Value::Array(b)) => {
628            let max_len = a.len().max(b.len());
629            for i in 0..max_len {
630                let a_val = a.get(i).unwrap_or(&Value::Null);
631                let b_val = b.get(i).unwrap_or(&Value::Null);
632                let next_path = format!("{}/{}", pointer, i);
633                diff_and_update_versions_internal(tracker, &next_path, a_val, b_val);
634            }
635        }
636        (old_val, new_val) => {
637            if old_val != new_val {
638                tracker.bump(pointer);
639
640                // If either side contains nested structures (e.g. Object replaced by Null, or vice versa)
641                // we must recursively bump all paths inside them so targeted cache entries invalidate.
642                if old_val.is_object() || old_val.is_array() {
643                    traverse_and_bump(tracker, pointer, old_val);
644                }
645                if new_val.is_object() || new_val.is_array() {
646                    traverse_and_bump(tracker, pointer, new_val);
647                }
648            }
649        }
650    }
651}
652
653/// Recursively traverses a value and bumps the version for every nested path.
654/// Used when a structural type mismatch occurs (e.g., Object -> Null) so that
655/// cache entries depending on nested fields are correctly invalidated.
656fn traverse_and_bump(tracker: &mut VersionTracker, pointer: &str, val: &Value) {
657    match val {
658        Value::Object(map) => {
659            for (key, v) in map {
660                if key == "$params" {
661                    continue; // Skip the special top-level params branch if it leaked here
662                }
663                let escaped_key = key.replace('~', "~0").replace('/', "~1");
664                let next_path = format!("{}/{}", pointer, escaped_key);
665                tracker.bump(&next_path);
666                traverse_and_bump(tracker, &next_path, v);
667            }
668        }
669        Value::Array(arr) => {
670            for (i, v) in arr.iter().enumerate() {
671                let next_path = format!("{}/{}", pointer, i);
672                tracker.bump(&next_path);
673                traverse_and_bump(tracker, &next_path, v);
674            }
675        }
676        _ => {}
677    }
678}