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
76/// A cached evaluation result with the specific dependency versions it was evaluated against
77#[derive(Clone)]
78pub struct CacheEntry {
79    pub dep_versions: HashMap<String, u64>,
80    pub result: Value,
81    /// The `active_item_index` this entry was computed under.
82    /// `None` = computed during main-form evaluation (safe to reuse across all items
83    /// provided the dep versions match). `Some(idx)` = computed for a specific item;
84    /// Tier-2 reuse is restricted to entries whose deps are entirely `$params`-scoped.
85    pub computed_for_item: Option<usize>,
86}
87
88/// Independent cache state for a single item in a subform array
89#[derive(Default, Clone)]
90pub struct SubformItemCache {
91    pub data_versions: VersionTracker,
92    pub entries: HashMap<String, CacheEntry>,
93    pub item_snapshot: Value,
94}
95
96impl SubformItemCache {
97    pub fn new() -> Self {
98        Self {
99            data_versions: VersionTracker::new(),
100            entries: HashMap::new(),
101            item_snapshot: Value::Null,
102        }
103    }
104}
105
106/// Primary cache structure for a JSON evaluation instance
107#[derive(Clone)]
108pub struct EvalCache {
109    pub data_versions: VersionTracker,
110    pub params_versions: VersionTracker,
111    pub entries: HashMap<String, CacheEntry>,
112
113    pub active_item_index: Option<usize>,
114    pub subform_caches: HashMap<usize, SubformItemCache>,
115
116    /// Monotonically increasing counter bumped whenever data_versions or params_versions change.
117    /// When `eval_generation == last_evaluated_generation`, all cache entries are guaranteed valid
118    /// and `evaluate_internal` can skip the full tree traversal.
119    pub eval_generation: u64,
120    pub last_evaluated_generation: u64,
121
122    /// Snapshot of the last fully-diffed main-form data payload.
123    /// Stored after each successful `evaluate_internal_with_new_data` call so the next
124    /// invocation can avoid an extra `snapshot_data_clone()` when computing the diff.
125    pub main_form_snapshot: Option<Value>,
126}
127
128impl Default for EvalCache {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl EvalCache {
135    pub fn new() -> Self {
136        Self {
137            data_versions: VersionTracker::new(),
138            params_versions: VersionTracker::new(),
139            entries: HashMap::new(),
140            active_item_index: None,
141            subform_caches: HashMap::new(),
142            eval_generation: 0,
143            last_evaluated_generation: u64::MAX, // force first evaluate_internal to run
144            main_form_snapshot: None,
145        }
146    }
147
148    pub fn clear(&mut self) {
149        self.data_versions = VersionTracker::new();
150        self.params_versions = VersionTracker::new();
151        self.entries.clear();
152        self.active_item_index = None;
153        self.subform_caches.clear();
154        self.eval_generation = 0;
155        self.last_evaluated_generation = u64::MAX;
156        self.main_form_snapshot = None;
157    }
158
159    /// Remove item caches for indices >= `current_count`.
160    /// Call this whenever the subform array length is known to have shrunk so that
161    /// stale per-item version trackers and cached entries do not linger in memory.
162    pub fn prune_subform_caches(&mut self, current_count: usize) {
163        self.subform_caches.retain(|&idx, _| idx < current_count);
164    }
165
166    /// Invalidate all `$params`-scoped table cache entries for a specific item.
167    ///
168    /// Called when a brand-new subform item is introduced so that `$params` tables
169    /// that aggregate array data (e.g. WOP_RIDERS) are forced to recompute instead
170    /// of returning stale results cached from a prior main-form evaluation that ran
171    /// when the item was absent (and thus saw zero/null for that item's values).
172    pub fn invalidate_params_tables_for_item(&mut self, idx: usize, table_keys: &[String]) {
173        // Bump params_versions so T2 global entries for these tables are stale.
174        for key in table_keys {
175            let data_path = crate::jsoneval::path_utils::normalize_to_json_pointer(key)
176                .replace("/properties/", "/");
177            let data_path = data_path.trim_start_matches('#');
178            let data_path = if data_path.starts_with('/') {
179                data_path.to_string()
180            } else {
181                format!("/{}", data_path)
182            };
183            self.params_versions.bump(&data_path);
184            self.eval_generation += 1;
185        }
186
187        // Evict matching T1 (item-level) entries so they are not reused.
188        if let Some(item_cache) = self.subform_caches.get_mut(&idx) {
189            for key in table_keys {
190                item_cache.entries.remove(key);
191            }
192        }
193    }
194
195    /// Returns true if evaluate_internal must run (versions changed since last full evaluation)
196    pub fn needs_full_evaluation(&self) -> bool {
197        self.eval_generation != self.last_evaluated_generation
198    }
199
200    /// Call after evaluate_internal completes successfully to mark the generation stable
201    pub fn mark_evaluated(&mut self) {
202        self.last_evaluated_generation = self.eval_generation;
203    }
204
205    pub(crate) fn ensure_active_item_cache(&mut self, idx: usize) {
206        self.subform_caches
207            .entry(idx)
208            .or_insert_with(SubformItemCache::new);
209    }
210
211    pub fn set_active_item(&mut self, idx: usize) {
212        self.active_item_index = Some(idx);
213        self.ensure_active_item_cache(idx);
214    }
215
216    pub fn clear_active_item(&mut self) {
217        self.active_item_index = None;
218    }
219
220    /// Recursively diffs `old` against `new` and bumps version for every changed data path scalar.
221    pub fn store_snapshot_and_diff_versions(&mut self, old: &Value, new: &Value) {
222        if let Some(idx) = self.active_item_index {
223            self.ensure_active_item_cache(idx);
224            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
225            diff_and_update_versions(&mut sub_cache.data_versions, "", old, new);
226            sub_cache.item_snapshot = new.clone();
227        } else {
228            diff_and_update_versions(&mut self.data_versions, "", old, new);
229        }
230    }
231
232    pub fn get_active_snapshot(&self) -> Value {
233        if let Some(idx) = self.active_item_index {
234            self.subform_caches
235                .get(&idx)
236                .map(|c| c.item_snapshot.clone())
237                .unwrap_or(Value::Null)
238        } else {
239            Value::Null
240        }
241    }
242
243    pub fn diff_active_item(
244        &mut self,
245        field_key: &str,
246        old_sub_data: &Value,
247        new_sub_data: &Value,
248    ) {
249        if let Some(idx) = self.active_item_index {
250            self.ensure_active_item_cache(idx);
251            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
252
253            // Diff ONLY the localized item part, skipping the massive parent tree
254            let empty = Value::Null;
255            let old_item = old_sub_data.get(field_key).unwrap_or(&empty);
256            let new_item = new_sub_data.get(field_key).unwrap_or(&empty);
257
258            diff_and_update_versions(
259                &mut sub_cache.data_versions,
260                &format!("/{}", field_key),
261                old_item,
262                new_item,
263            );
264            sub_cache.item_snapshot = new_sub_data.clone();
265        }
266    }
267
268    pub fn bump_data_version(&mut self, data_path: &str) {
269        // Always signal that something changed so the parent's needs_full_evaluation()
270        // returns true even when the bump was item-scoped.
271        self.eval_generation += 1;
272        if let Some(idx) = self.active_item_index {
273            if let Some(cache) = self.subform_caches.get_mut(&idx) {
274                cache.data_versions.bump(data_path);
275            }
276        } else {
277            self.data_versions.bump(data_path);
278        }
279    }
280
281    pub fn bump_params_version(&mut self, data_path: &str) {
282        self.params_versions.bump(data_path);
283        self.eval_generation += 1;
284    }
285
286    /// Check if the `eval_key` result can be safely bypassed because dependencies are unchanged.
287    ///
288    /// Two-tier lookup:
289    /// - Tier 1: item-scoped entries in `subform_caches[idx]` — checked first when an active item is set
290    /// - Tier 2: global `self.entries` — allows Run 1 (main form) results to be reused in Run 2 (subform)
291    pub fn check_cache(&self, eval_key: &str, deps: &IndexSet<String>) -> Option<Value> {
292        if let Some(idx) = self.active_item_index {
293            // Tier 1: item-specific entries (always safe to reuse for the same index)
294            if let Some(cache) = self.subform_caches.get(&idx) {
295                if let Some(hit) =
296                    self.validate_entry(eval_key, deps, &cache.entries, &cache.data_versions)
297                {
298                    if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
299                        println!("Cache HIT [T1 idx={}] {}", idx, eval_key);
300                    }
301                    return Some(hit);
302                }
303            }
304
305            // Tier 2: global entries (may have been stored by main-form Run 1).
306            // Only reuse if the entry is index-safe:
307            //   (a) computed with no active item (main-form result), OR
308            //   (b) computed for the same item index, OR
309            //   (c) all deps are $params-scoped (truly index-independent)
310            let item_data_versions = self
311                .subform_caches
312                .get(&idx)
313                .map(|c| &c.data_versions)
314                .unwrap_or(&self.data_versions);
315
316            if let Some(entry) = self.entries.get(eval_key) {
317                let index_safe = match entry.computed_for_item {
318                    // Main-form entry (no active item when stored): only safe if ALL its deps
319                    // are $params-scoped. Non-$params deps (like /riders/prem_pay_period) mean
320                    // the formula result is rider-specific — using it for a different rider via
321                    // the batch fast path would corrupt eval_data and poison subsequent formulas.
322                    None => entry.dep_versions.keys().all(|p| p.starts_with("/$params")),
323                    Some(stored_idx) if stored_idx == idx => true,
324                    _ => entry.dep_versions.keys().all(|p| p.starts_with("/$params")),
325                };
326                if index_safe {
327                    let result =
328                        self.validate_entry(eval_key, deps, &self.entries, item_data_versions);
329                    if result.is_some() {
330                        if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
331                            println!(
332                                "Cache HIT [T2 idx={} for={:?}] {}",
333                                idx, entry.computed_for_item, eval_key
334                            );
335                        }
336                    }
337                    return result;
338                }
339            }
340
341            None
342        } else {
343            self.validate_entry(eval_key, deps, &self.entries, &self.data_versions)
344        }
345    }
346
347    fn validate_entry(
348        &self,
349        eval_key: &str,
350        deps: &IndexSet<String>,
351        entries: &HashMap<String, CacheEntry>,
352        data_versions: &VersionTracker,
353    ) -> Option<Value> {
354        let entry = entries.get(eval_key)?;
355        for dep in deps {
356            let data_dep_path = crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
357                .replace("/properties/", "/");
358
359            let current_ver = if data_dep_path.starts_with("/$params") {
360                self.params_versions.get(&data_dep_path)
361            } else {
362                data_versions.get(&data_dep_path)
363            };
364
365            if let Some(&cached_ver) = entry.dep_versions.get(&data_dep_path) {
366                if current_ver != cached_ver {
367                    if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
368                        println!(
369                            "Cache MISS {}: dep {} changed ({} -> {})",
370                            eval_key, data_dep_path, cached_ver, current_ver
371                        );
372                    }
373                    return None;
374                }
375            } else {
376                if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
377                    println!(
378                        "Cache MISS {}: dep {} missing from cache entry",
379                        eval_key, data_dep_path
380                    );
381                }
382                return None;
383            }
384        }
385        if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
386            println!("Cache HIT {}", eval_key);
387        }
388        Some(entry.result.clone())
389    }
390
391    /// Store the newly evaluated value and snapshot the dependency versions.
392    ///
393    /// Storage strategy:
394    /// - When an active item is set, store into `subform_caches[idx].entries` (item-scoped).
395    ///   This isolates per-rider results so different items with different data don't collide.
396    /// - The global `self.entries` is written only from the main form (no active item).
397    ///   Subforms can reuse these via the Tier 2 fallback in `check_cache`.
398    pub fn store_cache(&mut self, eval_key: &str, deps: &IndexSet<String>, result: Value) {
399        // Phase 1: snapshot dep versions using the correct data_versions tracker
400        let mut dep_versions = HashMap::with_capacity(deps.len());
401        {
402            let data_versions = if let Some(idx) = self.active_item_index {
403                self.ensure_active_item_cache(idx);
404                &self.subform_caches[&idx].data_versions
405            } else {
406                &self.data_versions
407            };
408
409            for dep in deps {
410                let data_dep_path = crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
411                    .replace("/properties/", "/");
412                let ver = if data_dep_path.starts_with("/$params") {
413                    self.params_versions.get(&data_dep_path)
414                } else {
415                    data_versions.get(&data_dep_path)
416                };
417                dep_versions.insert(data_dep_path, ver);
418            }
419        }
420
421        // Phase 2: insert into the correct tier, tagging with the current item index.
422        let computed_for_item = self.active_item_index;
423
424        // For $params-scoped entries, only bump params_versions when the result value
425        // actually changed relative to the canonical cached entry.
426        //
427        // For T1 stores (active_item set), we compare against T2 (global) first.
428        // T2 is the authoritative reference: if T2 already holds the same value,
429        // params_versions was already bumped for it — bumping again per-rider causes
430        // an O(riders × $params_formulas) version explosion that makes every downstream
431        // formula (TOTAL_WOP_SA, WOP_MULTIPLIER, COMMISSION_FACTOR…) miss on each rider.
432        if eval_key.starts_with("#/$params") {
433            let existing_result: Option<&Value> = if let Some(idx) = self.active_item_index {
434                // Check T2 (global) first — if T2 has same value, no need to bump again.
435                self.entries
436                    .get(eval_key)
437                    .map(|e| &e.result)
438                    .or_else(|| {
439                        self.subform_caches
440                            .get(&idx)
441                            .and_then(|c| c.entries.get(eval_key))
442                            .map(|e| &e.result)
443                    })
444            } else {
445                self.entries.get(eval_key).map(|e| &e.result)
446            };
447
448            let value_changed = existing_result.map_or(true, |r| r != &result);
449
450            if value_changed {
451                let data_path = crate::jsoneval::path_utils::normalize_to_json_pointer(eval_key)
452                    .replace("/properties/", "/");
453                let data_path = data_path.trim_start_matches('#').to_string();
454                let data_path = if data_path.starts_with('/') {
455                    data_path
456                } else {
457                    format!("/{}", data_path)
458                };
459
460                // Bump the explicit path and its table-level parent.
461                // Stop at slash_count < 3 — never bump /$params/others or /$params itself.
462                let mut current_path = data_path.as_str();
463                let mut slash_count = current_path.matches('/').count();
464
465                while slash_count >= 3 {
466                    self.params_versions.bump(current_path);
467                    if let Some(last_slash) = current_path.rfind('/') {
468                        current_path = &current_path[..last_slash];
469                        slash_count -= 1;
470                    } else {
471                        break;
472                    }
473                }
474
475                self.eval_generation += 1;
476            }
477        }
478
479        let entry = CacheEntry {
480            dep_versions,
481            result,
482            computed_for_item,
483        };
484
485        if let Some(idx) = self.active_item_index {
486            // Store item-scoped: isolates per-rider entries so riders with different data don't collide
487            self.subform_caches
488                .get_mut(&idx)
489                .unwrap()
490                .entries
491                .insert(eval_key.to_string(), entry.clone());
492
493            // For $params-scoped formulas, also promote to T2 (global entries).
494            // $params formulas are index-independent: all riders produce the same result.
495            // Without T2 promotion, each rider's first store compares against stale/missing T2
496            // and sees the value as "new" → bumps params_versions → O(riders) cascade.
497            // With T2 promotion, rider 1 finds rider 0's result in T2 → no bump → no cascade.
498            if eval_key.starts_with("#/$params") {
499                self.entries.insert(eval_key.to_string(), entry);
500            }
501        } else {
502            self.entries.insert(eval_key.to_string(), entry);
503        }
504    }
505}
506
507/// Recursive helper to walk JSON structures and bump specific leaf versions where they differ
508pub(crate) fn diff_and_update_versions(
509    tracker: &mut VersionTracker,
510    pointer: &str,
511    old: &Value,
512    new: &Value,
513) {
514    if pointer.is_empty() {
515        diff_and_update_versions_internal(tracker, "", old, new);
516    } else {
517        diff_and_update_versions_internal(tracker, pointer, old, new);
518    }
519}
520
521fn diff_and_update_versions_internal(
522    tracker: &mut VersionTracker,
523    pointer: &str,
524    old: &Value,
525    new: &Value,
526) {
527    if old == new {
528        return;
529    }
530
531    match (old, new) {
532        (Value::Object(a), Value::Object(b)) => {
533            let mut keys = HashSet::new();
534            for k in a.keys() {
535                keys.insert(k.as_str());
536            }
537            for k in b.keys() {
538                keys.insert(k.as_str());
539            }
540
541            for key in keys {
542                // Do not deep-diff $params at any nesting level — it is manually tracked
543                // via bump_params_version on evaluations. Skipping at root-only was insufficient
544                // when item data is diffed via a non-empty pointer prefix.
545                if key == "$params" {
546                    continue;
547                }
548
549                let a_val = a.get(key).unwrap_or(&Value::Null);
550                let b_val = b.get(key).unwrap_or(&Value::Null);
551
552                let escaped_key = key.replace('~', "~0").replace('/', "~1");
553                let next_path = format!("{}/{}", pointer, escaped_key);
554                diff_and_update_versions_internal(tracker, &next_path, a_val, b_val);
555            }
556        }
557        (Value::Array(a), Value::Array(b)) => {
558            let max_len = a.len().max(b.len());
559            for i in 0..max_len {
560                let a_val = a.get(i).unwrap_or(&Value::Null);
561                let b_val = b.get(i).unwrap_or(&Value::Null);
562                let next_path = format!("{}/{}", pointer, i);
563                diff_and_update_versions_internal(tracker, &next_path, a_val, b_val);
564            }
565        }
566        (old_val, new_val) => {
567            if old_val != new_val {
568                tracker.bump(pointer);
569
570                // If either side contains nested structures (e.g. Object replaced by Null, or vice versa)
571                // we must recursively bump all paths inside them so targeted cache entries invalidate.
572                if old_val.is_object() || old_val.is_array() {
573                    traverse_and_bump(tracker, pointer, old_val);
574                }
575                if new_val.is_object() || new_val.is_array() {
576                    traverse_and_bump(tracker, pointer, new_val);
577                }
578            }
579        }
580    }
581}
582
583/// Recursively traverses a value and bumps the version for every nested path.
584/// Used when a structural type mismatch occurs (e.g., Object -> Null) so that
585/// cache entries depending on nested fields are correctly invalidated.
586fn traverse_and_bump(tracker: &mut VersionTracker, pointer: &str, val: &Value) {
587    match val {
588        Value::Object(map) => {
589            for (key, v) in map {
590                if key == "$params" {
591                    continue; // Skip the special top-level params branch if it leaked here
592                }
593                let escaped_key = key.replace('~', "~0").replace('/', "~1");
594                let next_path = format!("{}/{}", pointer, escaped_key);
595                tracker.bump(&next_path);
596                traverse_and_bump(tracker, &next_path, v);
597            }
598        }
599        Value::Array(arr) => {
600            for (i, v) in arr.iter().enumerate() {
601                let next_path = format!("{}/{}", pointer, i);
602                tracker.bump(&next_path);
603                traverse_and_bump(tracker, &next_path, v);
604            }
605        }
606        _ => {}
607    }
608}