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