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