Skip to main content

json_eval_rs/jsoneval/
dependents.rs

1use super::JSONEval;
2use crate::jsoneval::cancellation::CancellationToken;
3use crate::jsoneval::json_parser;
4use crate::jsoneval::path_utils;
5use crate::jsoneval::path_utils::get_value_by_pointer_without_properties;
6use crate::jsoneval::path_utils::normalize_to_json_pointer;
7use crate::jsoneval::types::DependentItem;
8use crate::rlogic::{LogicId, RLogic};
9use crate::utils::clean_float_noise_scalar;
10use crate::EvalData;
11
12use indexmap::{IndexMap, IndexSet};
13use serde_json::Value;
14
15impl JSONEval {
16    /// Evaluate fields that depend on a changed path.
17    /// Processes all dependent fields transitively, then optionally performs a full
18    /// re-evaluation pass (for read-only / hide effects) and cascades into subforms.
19    pub fn evaluate_dependents(
20        &mut self,
21        changed_paths: &[String],
22        data: Option<&str>,
23        context: Option<&str>,
24        re_evaluate: bool,
25        token: Option<&CancellationToken>,
26        mut canceled_paths: Option<&mut Vec<String>>,
27        include_subforms: bool,
28    ) -> Result<Value, String> {
29        if let Some(t) = token {
30            if t.is_cancelled() {
31                return Err("Cancelled".to_string());
32            }
33        }
34        let _lock = self.eval_lock.lock().unwrap();
35
36        // Update data if provided, diff versions
37        if let Some(data_str) = data {
38            let data_value = json_parser::parse_json_str(data_str)?;
39            let context_value = if let Some(ctx) = context {
40                json_parser::parse_json_str(ctx)?
41            } else {
42                Value::Object(serde_json::Map::new())
43            };
44            let old_data = self.eval_data.snapshot_data_clone();
45            self.eval_data
46                .replace_data_and_context(data_value, context_value);
47            let new_data = self.eval_data.snapshot_data_clone();
48            self.eval_cache
49                .store_snapshot_and_diff_versions(&old_data, &new_data);
50        }
51
52        let mut result = Vec::new();
53        let mut processed = IndexSet::new();
54        let mut to_process: Vec<(String, bool)> = changed_paths
55            .iter()
56            .map(|path| (path_utils::dot_notation_to_schema_pointer(path), false))
57            .collect();
58
59        Self::process_dependents_queue(
60            &self.engine,
61            &self.evaluations,
62            &mut self.eval_data,
63            &mut self.eval_cache,
64            &self.dependents_evaluations,
65            &self.dep_formula_triggers,
66            &self.evaluated_schema,
67            &mut to_process,
68            &mut processed,
69            &mut result,
70            token,
71            canceled_paths.as_mut().map(|v| &mut **v),
72        )?;
73
74        // Drop the lock before calling sub-methods that may re-acquire it
75        drop(_lock);
76
77        if re_evaluate {
78            self.run_re_evaluate_pass(
79                token,
80                &mut to_process,
81                &mut processed,
82                &mut result,
83                canceled_paths.as_mut().map(|v| &mut **v),
84            )?;
85        }
86
87        if include_subforms {
88            // Augment changed_paths with every subform item field already written into result
89            // by the dependents queue and re-evaluate pass. Without this, when a main-form
90            // dependent rule writes to e.g. `riders.0.benefit`, that path never appears in
91            // `item_changed_paths` inside run_subform_pass → the item is skipped → the
92            // subform item's own `benefit.dependents` never fire.
93            let extended_paths: Vec<String> = {
94                let mut paths = changed_paths.to_vec();
95                for item in &result {
96                    if let Some(ref_val) = item.get("$ref").and_then(|v| v.as_str()) {
97                        let s = ref_val.to_string();
98                        if !paths.contains(&s) {
99                            paths.push(s);
100                        }
101                    }
102                }
103                paths
104            };
105            self.run_subform_pass(&extended_paths, re_evaluate, token, &mut result)?;
106        }
107
108        // Deduplicate by $ref — keep the last entry for each path.
109        // Multiple passes (dependents queue, re-evaluate, subform) may independently emit
110        // the same $ref when cache versions cause overlapping detections. The subform pass
111        // result is most specific and wins because it is appended last.
112        let deduped = {
113            let mut seen: IndexMap<String, usize> = IndexMap::new();
114            for (i, item) in result.iter().enumerate() {
115                if let Some(r) = item.get("$ref").and_then(|v| v.as_str()) {
116                    seen.insert(r.to_string(), i);
117                }
118            }
119            let last_indices: IndexSet<usize> = seen.values().copied().collect();
120            let out: Vec<Value> = result
121                .into_iter()
122                .enumerate()
123                .filter(|(i, _)| last_indices.contains(i))
124                .map(|(_, item)| item)
125                .collect();
126            out
127        };
128
129        // Refresh main_form_snapshot so the next evaluate() call computes diffs from the
130        // post-dependents state instead of the old pre-dependents snapshot.
131        // Without this, evaluate() re-diffs every field that evaluate_dependents already
132        // processed, double-bumping data_versions and causing spurious cache misses in
133        // evaluate_internal (observed as unexpected ~550ms "cache hit" full evaluates).
134        // Only update when no subform item is active — subform evaluate_dependents calls
135        // must not overwrite the parent's snapshot.
136        if self.eval_cache.active_item_index.is_none() {
137            let current_snapshot = self.eval_data.snapshot_data_clone();
138            self.eval_cache.main_form_snapshot = Some(current_snapshot);
139        }
140
141        Ok(Value::Array(deduped))
142    }
143
144    /// Full re-evaluation pass: runs `evaluate_internal`, then applies read-only fixes and
145    /// recursive hide effects, feeding any newly-generated changes back into the dependents queue.
146    fn run_re_evaluate_pass(
147        &mut self,
148        token: Option<&CancellationToken>,
149        to_process: &mut Vec<(String, bool)>,
150        processed: &mut IndexSet<String>,
151        result: &mut Vec<Value>,
152        mut canceled_paths: Option<&mut Vec<String>>,
153    ) -> Result<(), String> {
154        // --- Schema Default Value Pass (Before Eval) ---
155        self.run_schema_default_value_pass(
156            token,
157            to_process,
158            processed,
159            result,
160            canceled_paths.as_mut().map(|v| &mut **v),
161        )?;
162
163        // Resolve the correct data_versions tracker before snapshotting.
164        // When active_item_index is Some(idx), evaluate_internal bumps
165        // subform_caches[idx].data_versions — NOT the main data_versions.
166        // Using the main tracker for both snapshot and post-eval lookup would make
167        // old_ver == new_ver always, so no changed values would ever be emitted.
168        let pre_eval_versions = if let Some(idx) = self.eval_cache.active_item_index {
169            self.eval_cache
170                .subform_caches
171                .get(&idx)
172                .map(|c| c.data_versions.clone())
173                .unwrap_or_else(|| self.eval_cache.data_versions.clone())
174        } else {
175            self.eval_cache.data_versions.clone()
176        };
177
178        self.evaluate_internal(None, token)?;
179
180        // --- Schema Default Value Pass (After Eval) ---
181        self.run_schema_default_value_pass(
182            token,
183            to_process,
184            processed,
185            result,
186            canceled_paths.as_mut().map(|v| &mut **v),
187        )?;
188
189        // Emit result entries for every sorted-evaluation whose version uniquely bumped.
190        // Again resolve to the per-item tracker so the comparison uses the same source
191        // that evaluate_internal wrote into.
192        let active_idx = self.eval_cache.active_item_index;
193        for eval_key in self.sorted_evaluations.iter().flatten() {
194            if eval_key.contains("/$params/") || eval_key.contains("/$") {
195                continue;
196            }
197
198            let schema_ptr = path_utils::normalize_to_json_pointer(eval_key);
199            let data_path = schema_ptr
200                .replace("/properties/", "/")
201                .trim_start_matches('#')
202                .trim_start_matches('/')
203                .to_string();
204
205            let version_path = format!("/{}", data_path);
206            let old_ver = pre_eval_versions.get(&version_path);
207            let new_ver = if let Some(idx) = active_idx {
208                self.eval_cache
209                    .subform_caches
210                    .get(&idx)
211                    .map(|c| c.data_versions.get(&version_path))
212                    .unwrap_or_else(|| self.eval_cache.data_versions.get(&version_path))
213            } else {
214                self.eval_cache.data_versions.get(&version_path)
215            };
216
217            if new_ver > old_ver {
218                if let Some(new_val) = self.evaluated_schema.pointer(&schema_ptr) {
219                    let dot_path = data_path.trim_end_matches("/value").replace('/', ".");
220                    let mut obj = serde_json::Map::new();
221                    obj.insert("$ref".to_string(), Value::String(dot_path));
222                    let is_clear = new_val == &Value::Null || new_val.as_str() == Some("");
223                    if is_clear {
224                        obj.insert("clear".to_string(), Value::Bool(true));
225                    } else {
226                        obj.insert("value".to_string(), new_val.clone());
227                    }
228                    result.push(Value::Object(obj));
229                }
230            }
231        }
232
233        // Re-acquire lock for post-eval passes
234        let _lock = self.eval_lock.lock().unwrap();
235
236        // --- Read-Only Pass ---
237        let mut readonly_changes = Vec::new();
238        let mut readonly_values = Vec::new();
239        for path in self.conditional_readonly_fields.iter() {
240            let normalized = path_utils::normalize_to_json_pointer(path);
241            if let Some(schema_el) = self.evaluated_schema.pointer(&normalized) {
242                self.check_readonly_for_dependents(
243                    schema_el,
244                    path,
245                    &mut readonly_changes,
246                    &mut readonly_values,
247                );
248            }
249        }
250        for (path, schema_value) in readonly_changes {
251            let data_path = path_utils::normalize_to_json_pointer(&path)
252                .replace("/properties/", "/")
253                .trim_start_matches('#')
254                .to_string();
255            self.eval_data.set(&data_path, schema_value.clone());
256            self.eval_cache.bump_data_version(&data_path);
257            to_process.push((path, true));
258        }
259        for (path, schema_value) in readonly_values {
260            let data_path = path_utils::normalize_to_json_pointer(&path)
261                .replace("/properties/", "/")
262                .trim_start_matches('#')
263                .to_string();
264            let mut obj = serde_json::Map::new();
265            obj.insert(
266                "$ref".to_string(),
267                Value::String(path_utils::pointer_to_dot_notation(&data_path)),
268            );
269            obj.insert("$readonly".to_string(), Value::Bool(true));
270            let is_clear = schema_value == Value::Null || schema_value.as_str() == Some("");
271            if is_clear {
272                obj.insert("clear".to_string(), Value::Bool(true));
273            } else {
274                obj.insert("value".to_string(), schema_value);
275            }
276            result.push(Value::Object(obj));
277        }
278
279        // When readonly fields were updated during a subform item evaluation, `$params`
280        // tables that aggregate rider data (e.g. WOP_RIDERS) may still hold stale cached
281        // results — their static external_deps do not include individual rider paths like
282        // /riders/loading_benefit/first_prem, so a version bump on first_prem alone is
283        // not enough to bust their T1 cache entry. Re-running evaluate_internal (after
284        // invalidating their T1 entries) forces a fresh recomputation with the updated data.
285        let had_readonly_changes = !to_process.is_empty();
286        if had_readonly_changes {
287            if let Some(active_idx) = self.eval_cache.active_item_index {
288                let params_table_keys: Vec<String> = self
289                    .table_metadata
290                    .keys()
291                    .filter(|k| k.starts_with("#/$params"))
292                    .cloned()
293                    .collect();
294                if !params_table_keys.is_empty() {
295                    self.eval_cache
296                        .invalidate_params_tables_for_item(active_idx, &params_table_keys);
297                }
298                drop(_lock);
299                self.evaluate_internal(None, token)?;
300                // After re-evaluation, we need to explicitly re-acquire the lock if we continue processing
301                // However, since `to_process` loop runs immediately after without needing the exact lock structure here,
302                // we can let the loop acquire what it needs.
303            }
304        }
305
306        if !to_process.is_empty() {
307            Self::process_dependents_queue(
308                &self.engine,
309                &self.evaluations,
310                &mut self.eval_data,
311                &mut self.eval_cache,
312                &self.dependents_evaluations,
313                &self.dep_formula_triggers,
314                &self.evaluated_schema,
315                to_process,
316                processed,
317                result,
318                token,
319                canceled_paths.as_mut().map(|v| &mut **v),
320            )?;
321        }
322
323        // --- Recursive Hide Pass ---
324        let mut hidden_fields = Vec::new();
325        for path in self.conditional_hidden_fields.iter() {
326            let normalized = path_utils::normalize_to_json_pointer(path);
327            if let Some(schema_el) = self.evaluated_schema.pointer(&normalized) {
328                self.check_hidden_field(schema_el, path, &mut hidden_fields);
329            }
330        }
331        if !hidden_fields.is_empty() {
332            Self::recursive_hide_effect(
333                &self.engine,
334                &self.evaluations,
335                &self.reffed_by,
336                &mut self.eval_data,
337                &mut self.eval_cache,
338                hidden_fields,
339                to_process,
340                result,
341            );
342        }
343        if !to_process.is_empty() {
344            Self::process_dependents_queue(
345                &self.engine,
346                &self.evaluations,
347                &mut self.eval_data,
348                &mut self.eval_cache,
349                &self.dependents_evaluations,
350                &self.dep_formula_triggers,
351                &self.evaluated_schema,
352                to_process,
353                processed,
354                result,
355                token,
356                canceled_paths.as_mut().map(|v| &mut **v),
357            )?;
358        }
359
360        Ok(())
361    }
362
363    /// Internal method to run the schema default value pass.
364    /// Filters for only primitive schema values (not $evaluation objects).
365    fn run_schema_default_value_pass(
366        &mut self,
367        token: Option<&CancellationToken>,
368        to_process: &mut Vec<(String, bool)>,
369        processed: &mut IndexSet<String>,
370        result: &mut Vec<Value>,
371        mut canceled_paths: Option<&mut Vec<String>>,
372    ) -> Result<(), String> {
373        let mut default_value_changes = Vec::new();
374        let schema_values = self.get_schema_value_array();
375
376        if let Value::Array(values) = schema_values {
377            for item in values {
378                if let Value::Object(map) = item {
379                    if let (Some(Value::String(dot_path)), Some(schema_val)) =
380                        (map.get("path"), map.get("value"))
381                    {
382                        let schema_ptr = path_utils::dot_notation_to_schema_pointer(dot_path);
383                        if let Some(Value::Object(schema_node)) = self
384                            .evaluated_schema
385                            .pointer(schema_ptr.trim_start_matches('#'))
386                        {
387                            if let Some(Value::Object(condition)) = schema_node.get("condition") {
388                                if let Some(hidden_val) = condition.get("hidden") {
389                                    // Skip if hidden is true OR if it's a non-primitive value (formula object)
390                                    if !hidden_val.is_boolean()
391                                        || hidden_val.as_bool() == Some(true)
392                                    {
393                                        continue;
394                                    }
395                                }
396                            }
397                        }
398
399                        let data_path = dot_path.replace('.', "/");
400                        let current_data = self
401                            .eval_data
402                            .data()
403                            .pointer(&format!("/{}", data_path))
404                            .unwrap_or(&Value::Null);
405
406                        let is_empty = match current_data {
407                            Value::Null => true,
408                            Value::String(s) if s.is_empty() => true,
409                            _ => false,
410                        };
411
412                        let is_schema_val_empty = match schema_val {
413                            Value::Null => true,
414                            Value::String(s) if s.is_empty() => true,
415                            Value::Object(map) if map.contains_key("$evaluation") => true,
416                            _ => false,
417                        };
418
419                        if is_empty && !is_schema_val_empty && current_data != schema_val {
420                            default_value_changes.push((
421                                data_path,
422                                schema_val.clone(),
423                                dot_path.clone(),
424                            ));
425                        }
426                    }
427                }
428            }
429        }
430
431        let mut has_changes = false;
432        for (data_path, schema_val, dot_path) in default_value_changes {
433            self.eval_data
434                .set(&format!("/{}", data_path), schema_val.clone());
435            self.eval_cache
436                .bump_data_version(&format!("/{}", data_path));
437
438            let mut change_obj = serde_json::Map::new();
439            change_obj.insert("$ref".to_string(), Value::String(dot_path));
440            let is_clear = schema_val == Value::Null || schema_val.as_str() == Some("");
441            if is_clear {
442                change_obj.insert("clear".to_string(), Value::Bool(true));
443            } else {
444                change_obj.insert("value".to_string(), schema_val);
445            }
446            result.push(Value::Object(change_obj));
447
448            let schema_ptr = format!("#/{}", data_path.replace('/', "/properties/"));
449            to_process.push((schema_ptr, true));
450            has_changes = true;
451        }
452
453        if has_changes {
454            Self::process_dependents_queue(
455                &self.engine,
456                &self.evaluations,
457                &mut self.eval_data,
458                &mut self.eval_cache,
459                &self.dependents_evaluations,
460                &self.dep_formula_triggers,
461                &self.evaluated_schema,
462                to_process,
463                processed,
464                result,
465                token,
466                canceled_paths.as_mut().map(|v| &mut **v),
467            )?;
468        }
469
470        Ok(())
471    }
472
473    /// Cascade dependency evaluation into each subform item.
474    ///
475    /// For every registered subform, this method iterates over its array items and runs
476    /// `evaluate_dependents` on the subform using the cache-swap strategy so the subform
477    /// can see global main-form Tier 2 cache entries (avoiding redundant table re-evaluation).
478    ///
479    /// `sub_re_evaluate` is set **only** when the parent's bumped `data_versions` intersect
480    /// with paths the subform actually depends on — preventing expensive full re-evals on
481    /// subform items whose dependencies did not change.
482    fn run_subform_pass(
483        &mut self,
484        changed_paths: &[String],
485        re_evaluate: bool,
486        token: Option<&CancellationToken>,
487        result: &mut Vec<Value>,
488    ) -> Result<(), String> {
489        // Collect subform paths once (avoids holding borrow on self.subforms during mutation)
490        let subform_paths: Vec<String> = self.subforms.keys().cloned().collect();
491
492        for subform_path in subform_paths {
493            let field_key = subform_field_key(&subform_path);
494            // Compute dotted path and prefix strings once per subform, not per item
495            let subform_dot_path =
496                path_utils::pointer_to_dot_notation(&subform_path).replace(".properties.", ".");
497            let field_prefix = format!("{}.", field_key);
498            let subform_ptr = normalize_to_json_pointer(&subform_path);
499
500            // Borrow only the item count first — avoid cloning the full array
501            let item_count =
502                get_value_by_pointer_without_properties(self.eval_data.data(), &subform_ptr)
503                    .and_then(|v| v.as_array())
504                    .map(|a| a.len())
505                    .unwrap_or(0);
506
507            if item_count == 0 {
508                continue;
509            }
510
511            // Evict stale per-item caches for indices that no longer exist in the array.
512            // This prevents memory leaks when riders are removed and the array shrinks.
513            self.eval_cache.prune_subform_caches(item_count);
514
515            // When the parent ran a re_evaluate pass, always pass re_evaluate:true to subforms.
516            // The parent's evaluate_internal may have updated $params or other referenced values
517            // that the subform formulas read, even if none of the subform's own dep paths bumped.
518            let global_sub_re_evaluate = re_evaluate;
519
520            // Snapshot the parent's version trackers once, before iterating any riders.
521            // Using the live `parent_cache.data_versions` inside the loop would let rider N's
522            // evaluation bumps contaminate the merge_from baseline for rider M (M ≠ N),
523            // causing cache misses and wrong re-evaluations on subsequent visits to rider M.
524            let parent_data_versions_snapshot = self.eval_cache.data_versions.clone();
525            let parent_params_versions_snapshot = self.eval_cache.params_versions.clone();
526
527            for idx in 0..item_count {
528                // Map absolute changed paths → subform-internal paths for this item index
529                let prefix_dot = format!("{}.{}.", subform_dot_path, idx);
530                let prefix_bracket = format!("{}[{}].", subform_dot_path, idx);
531                let prefix_field_bracket = format!("{}[{}].", field_key, idx);
532
533                let item_changed_paths: Vec<String> = changed_paths
534                    .iter()
535                    .filter_map(|p| {
536                        if p.starts_with(&prefix_bracket) {
537                            Some(p.replacen(&prefix_bracket, &field_prefix, 1))
538                        } else if p.starts_with(&prefix_dot) {
539                            Some(p.replacen(&prefix_dot, &field_prefix, 1))
540                        } else if p.starts_with(&prefix_field_bracket) {
541                            Some(p.replacen(&prefix_field_bracket, &field_prefix, 1))
542                        } else {
543                            None
544                        }
545                    })
546                    .collect();
547
548                let sub_re_evaluate = global_sub_re_evaluate || !item_changed_paths.is_empty();
549
550                // Skip entirely if there's nothing to do for this item
551                if !sub_re_evaluate && item_changed_paths.is_empty() {
552                    continue;
553                }
554
555                // Build minimal merged data: clone only item at idx, share $params shallowly.
556                // This avoids cloning the full 5MB parent payload for every item.
557                let item_val =
558                    get_value_by_pointer_without_properties(self.eval_data.data(), &subform_ptr)
559                        .and_then(|v| v.as_array())
560                        .and_then(|a| a.get(idx))
561                        .cloned()
562                        .unwrap_or(Value::Null);
563
564                // Build a minimal parent object with only the fields the subform needs:
565                // the item under field_key, plus all non-array top-level parent fields
566                // ($params markers, scalars). Large arrays are already stripped to static_arrays.
567                let merged_data = {
568                    let parent = self.eval_data.data();
569                    let mut map = serde_json::Map::new();
570                    if let Value::Object(parent_map) = parent {
571                        for (k, v) in parent_map {
572                            if k == &field_key {
573                                // Will be overridden with the single item below
574                                continue;
575                            }
576                            // Include scalars, objects ($params markers, etc.) but skip
577                            // other large array fields that aren't this subform
578                            if !v.is_array() {
579                                map.insert(k.clone(), v.clone());
580                            }
581                        }
582                    }
583                    map.insert(field_key.clone(), item_val.clone());
584                    Value::Object(map)
585                };
586
587                let Some(subform) = self.subforms.get_mut(&subform_path) else {
588                    continue;
589                };
590
591                // Prepare cache state for this item
592                self.eval_cache.ensure_active_item_cache(idx);
593                let old_item_val = self
594                    .eval_cache
595                    .subform_caches
596                    .get(&idx)
597                    .map(|c| c.item_snapshot.clone())
598                    .unwrap_or(Value::Null);
599
600                subform.eval_data.replace_data_and_context(
601                    merged_data,
602                    self.eval_data
603                        .data()
604                        .get("$context")
605                        .cloned()
606                        .unwrap_or(Value::Null),
607                );
608                let new_item_val = subform
609                    .eval_data
610                    .data()
611                    .get(&field_key)
612                    .cloned()
613                    .unwrap_or(Value::Null);
614
615                // Cache-swap: lend parent cache to subform
616                let mut parent_cache = std::mem::take(&mut self.eval_cache);
617                parent_cache.ensure_active_item_cache(idx);
618                if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
619                    // Merge all data versions from the parent snapshot. We must include non-$params
620                    // paths so that parent field updates (like wop_basic_benefit changing) correctly
621                    // invalidate subform per-item cache entries that depend on them.
622                    c.data_versions.merge_from(&parent_data_versions_snapshot);
623                    // Always reflect the latest $params (schema-level, index-independent).
624                    c.data_versions
625                        .merge_from_params(&parent_params_versions_snapshot);
626                    crate::jsoneval::eval_cache::diff_and_update_versions(
627                        &mut c.data_versions,
628                        &format!("/{}", field_key),
629                        &old_item_val,
630                        &new_item_val,
631                    );
632                    c.item_snapshot = new_item_val;
633                }
634                parent_cache.set_active_item(idx);
635                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
636
637                let subform_result = subform.evaluate_dependents(
638                    &item_changed_paths,
639                    None,
640                    None,
641                    sub_re_evaluate,
642                    token,
643                    None,
644                    false,
645                );
646
647                // Restore parent cache
648                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
649                parent_cache.clear_active_item();
650
651                // Propagate the updated item_snapshot from the parent's T1 cache into the
652                // subform's own eval_cache. Without this, subsequent evaluate_subform() calls
653                // for this idx read the OLD snapshot (pre-run_subform_pass) and see a diff
654                // against the new data → item_paths_bumped = true → spurious table invalidation.
655                if let Some(parent_item_cache) = self.eval_cache.subform_caches.get(&idx) {
656                    let snapshot = parent_item_cache.item_snapshot.clone();
657                    subform.eval_cache.ensure_active_item_cache(idx);
658                    if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
659                        sub_cache.item_snapshot = snapshot;
660                    }
661                }
662
663                self.eval_cache = parent_cache;
664
665                if let Ok(Value::Array(changes)) = subform_result {
666                    for change in changes {
667                        if let Some(obj) = change.as_object() {
668                            if let Some(Value::String(ref_path)) = obj.get("$ref") {
669                                // Remap the $ref path to include the parent path + item index
670                                let new_ref = if ref_path.starts_with(&field_prefix) {
671                                    format!(
672                                        "{}.{}.{}",
673                                        subform_dot_path,
674                                        idx,
675                                        &ref_path[field_prefix.len()..]
676                                    )
677                                } else {
678                                    format!("{}.{}.{}", subform_dot_path, idx, ref_path)
679                                };
680
681                                // Write the computed value back to parent eval_data so subsequent
682                                // evaluate_subform calls see an up-to-date old_item_snapshot.
683                                // Without this, the diff in with_item_cache_swap sees stale parent
684                                // data vs the new call's apply_changes values → spurious item bumps
685                                // → invalidate_params_tables_for_item fires → eval_generation bumps.
686                                if let Some(val) = obj.get("value") {
687                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
688                                    self.eval_data.set(&data_ptr, val.clone());
689                                } else if obj.get("clear").and_then(Value::as_bool) == Some(true) {
690                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
691                                    self.eval_data.set(&data_ptr, Value::Null);
692                                }
693
694                                let mut new_obj = obj.clone();
695                                new_obj.insert("$ref".to_string(), Value::String(new_ref));
696                                result.push(Value::Object(new_obj));
697                            } else {
698                                // No $ref rewrite needed — push as-is without cloning the map
699                                result.push(change);
700                            }
701                        }
702                    }
703                }
704            }
705        }
706        Ok(())
707    }
708
709    /// Helper to evaluate a dependent value - uses pre-compiled eval keys for fast lookup
710    pub(crate) fn evaluate_dependent_value_static(
711        engine: &RLogic,
712        evaluations: &IndexMap<String, LogicId>,
713        eval_data: &EvalData,
714        value: &Value,
715        changed_field_value: &Value,
716        changed_field_ref_value: &Value,
717    ) -> Result<Value, String> {
718        match value {
719            // If it's a String, check if it's an eval key reference
720            Value::String(eval_key) => {
721                if let Some(logic_id) = evaluations.get(eval_key) {
722                    // It's a pre-compiled evaluation - run it with scoped context
723                    // Create internal context with $value and $refValue
724                    let mut internal_context = serde_json::Map::new();
725                    internal_context.insert("$value".to_string(), changed_field_value.clone());
726                    internal_context.insert("$refValue".to_string(), changed_field_ref_value.clone());
727                    let context_value = Value::Object(internal_context);
728
729                    let result = engine.run_with_context(logic_id, eval_data.data(), &context_value)
730                        .map_err(|e| format!("Failed to evaluate dependent logic '{}': {}", eval_key, e))?;
731                    Ok(result)
732                } else {
733                    // It's a regular string value
734                    Ok(value.clone())
735                }
736            }
737            // For backwards compatibility: compile $evaluation on-the-fly
738            // This shouldn't happen with properly parsed schemas
739            Value::Object(map) if map.contains_key("$evaluation") => {
740                Err("Dependent evaluation contains unparsed $evaluation - schema was not properly parsed".to_string())
741            }
742            // Primitive value - return as-is
743            _ => Ok(value.clone()),
744        }
745    }
746
747    /// Check if a single field is readonly and populate vectors for both changes and all values
748    pub(crate) fn check_readonly_for_dependents(
749        &self,
750        schema_element: &Value,
751        path: &str,
752        changes: &mut Vec<(String, Value)>,
753        all_values: &mut Vec<(String, Value)>,
754    ) {
755        match schema_element {
756            Value::Object(map) => {
757                // Check if field is disabled (ReadOnly)
758                let mut is_disabled = false;
759                if let Some(Value::Object(condition)) = map.get("condition") {
760                    if let Some(Value::Bool(d)) = condition.get("disabled") {
761                        is_disabled = *d;
762                    }
763                }
764
765                // Check skipReadOnlyValue config
766                let mut skip_readonly = false;
767                if let Some(Value::Object(config)) = map.get("config") {
768                    if let Some(Value::Object(all)) = config.get("all") {
769                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
770                            skip_readonly = *skip;
771                        }
772                    }
773                }
774
775                if is_disabled && !skip_readonly {
776                    if let Some(schema_value) = map.get("value") {
777                        let data_path = path_utils::normalize_to_json_pointer(path)
778                            .replace("/properties/", "/")
779                            .trim_start_matches('#')
780                            .to_string();
781
782                        let current_data = self
783                            .eval_data
784                            .data()
785                            .pointer(&data_path)
786                            .unwrap_or(&Value::Null);
787
788                        // Add to all_values (include in dependents result regardless of change)
789                        all_values.push((path.to_string(), schema_value.clone()));
790
791                        // Only add to changes if value doesn't match
792                        if current_data != schema_value {
793                            changes.push((path.to_string(), schema_value.clone()));
794                        }
795                    }
796                }
797            }
798            _ => {}
799        }
800    }
801
802    /// Recursively collect read-only fields that need updates (Legacy/Full-Scan)
803    #[allow(dead_code)]
804    pub(crate) fn collect_readonly_fixes(
805        &self,
806        schema_element: &Value,
807        path: &str,
808        changes: &mut Vec<(String, Value)>,
809    ) {
810        match schema_element {
811            Value::Object(map) => {
812                // Check if field is disabled (ReadOnly)
813                let mut is_disabled = false;
814                if let Some(Value::Object(condition)) = map.get("condition") {
815                    if let Some(Value::Bool(d)) = condition.get("disabled") {
816                        is_disabled = *d;
817                    }
818                }
819
820                // Check skipReadOnlyValue config
821                let mut skip_readonly = false;
822                if let Some(Value::Object(config)) = map.get("config") {
823                    if let Some(Value::Object(all)) = config.get("all") {
824                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
825                            skip_readonly = *skip;
826                        }
827                    }
828                }
829
830                if is_disabled && !skip_readonly {
831                    // Check if it's a value field (has "value" property or implicit via path?)
832                    // In JS: "const readOnlyValues = this.getSchemaValues();"
833                    // We only care if data != schema value
834                    if let Some(schema_value) = map.get("value") {
835                        let data_path = path_utils::normalize_to_json_pointer(path)
836                            .replace("/properties/", "/")
837                            .trim_start_matches('#')
838                            .to_string();
839
840                        let current_data = self
841                            .eval_data
842                            .data()
843                            .pointer(&data_path)
844                            .unwrap_or(&Value::Null);
845
846                        if current_data != schema_value {
847                            changes.push((path.to_string(), schema_value.clone()));
848                        }
849                    }
850                }
851
852                // Recurse into properties
853                if let Some(Value::Object(props)) = map.get("properties") {
854                    for (key, val) in props {
855                        let next_path = if path == "#" {
856                            format!("#/properties/{}", key)
857                        } else {
858                            format!("{}/properties/{}", path, key)
859                        };
860                        self.collect_readonly_fixes(val, &next_path, changes);
861                    }
862                }
863            }
864            _ => {}
865        }
866    }
867
868    /// Check if a single field is hidden and needs clearing (Optimized non-recursive)
869    pub(crate) fn check_hidden_field(
870        &self,
871        schema_element: &Value,
872        path: &str,
873        hidden_fields: &mut Vec<String>,
874    ) {
875        match schema_element {
876            Value::Object(map) => {
877                // Check if field is hidden
878                let mut is_hidden = false;
879                if let Some(Value::Object(condition)) = map.get("condition") {
880                    if let Some(Value::Bool(h)) = condition.get("hidden") {
881                        is_hidden = *h;
882                    }
883                }
884
885                // Check keepHiddenValue config
886                let mut keep_hidden = false;
887                if let Some(Value::Object(config)) = map.get("config") {
888                    if let Some(Value::Object(all)) = config.get("all") {
889                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
890                            keep_hidden = *keep;
891                        }
892                    }
893                }
894
895                if is_hidden && !keep_hidden {
896                    let data_path = path_utils::normalize_to_json_pointer(path)
897                        .replace("/properties/", "/")
898                        .trim_start_matches('#')
899                        .to_string();
900
901                    let current_data = self
902                        .eval_data
903                        .data()
904                        .pointer(&data_path)
905                        .unwrap_or(&Value::Null);
906
907                    // If hidden and has non-empty value, add to list
908                    if current_data != &Value::Null && current_data != "" {
909                        hidden_fields.push(path.to_string());
910                    }
911                }
912            }
913            _ => {}
914        }
915    }
916
917    /// Recursively collect hidden fields that have values (candidates for clearing) (Legacy/Full-Scan)
918    #[allow(dead_code)]
919    pub(crate) fn collect_hidden_fields(
920        &self,
921        schema_element: &Value,
922        path: &str,
923        hidden_fields: &mut Vec<String>,
924    ) {
925        match schema_element {
926            Value::Object(map) => {
927                // Check if field is hidden
928                let mut is_hidden = false;
929                if let Some(Value::Object(condition)) = map.get("condition") {
930                    if let Some(Value::Bool(h)) = condition.get("hidden") {
931                        is_hidden = *h;
932                    }
933                }
934
935                // Check keepHiddenValue config
936                let mut keep_hidden = false;
937                if let Some(Value::Object(config)) = map.get("config") {
938                    if let Some(Value::Object(all)) = config.get("all") {
939                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
940                            keep_hidden = *keep;
941                        }
942                    }
943                }
944
945                if is_hidden && !keep_hidden {
946                    let data_path = path_utils::normalize_to_json_pointer(path)
947                        .replace("/properties/", "/")
948                        .trim_start_matches('#')
949                        .to_string();
950
951                    let current_data = self
952                        .eval_data
953                        .data()
954                        .pointer(&data_path)
955                        .unwrap_or(&Value::Null);
956
957                    // If hidden and has non-empty value, add to list
958                    if current_data != &Value::Null && current_data != "" {
959                        hidden_fields.push(path.to_string());
960                    }
961                }
962
963                // Recurse into children
964                for (key, val) in map {
965                    if key == "properties" {
966                        if let Value::Object(props) = val {
967                            for (p_key, p_val) in props {
968                                let next_path = if path == "#" {
969                                    format!("#/properties/{}", p_key)
970                                } else {
971                                    format!("{}/properties/{}", path, p_key)
972                                };
973                                self.collect_hidden_fields(p_val, &next_path, hidden_fields);
974                            }
975                        }
976                    } else if let Value::Object(_) = val {
977                        // Skip known metadata keys and explicitly handled keys
978                        if key == "condition"
979                            || key == "config"
980                            || key == "rules"
981                            || key == "dependents"
982                            || key == "hideLayout"
983                            || key == "$layout"
984                            || key == "$params"
985                            || key == "definitions"
986                            || key == "$defs"
987                            || key.starts_with('$')
988                        {
989                            continue;
990                        }
991
992                        let next_path = if path == "#" {
993                            format!("#/{}", key)
994                        } else {
995                            format!("{}/{}", path, key)
996                        };
997                        self.collect_hidden_fields(val, &next_path, hidden_fields);
998                    }
999                }
1000            }
1001            _ => {}
1002        }
1003    }
1004
1005    /// Perform recursive hiding effect using reffed_by graph.
1006    /// Collects every data path that gets nulled into `invalidated_paths`.
1007    pub(crate) fn recursive_hide_effect(
1008        engine: &RLogic,
1009        evaluations: &IndexMap<String, LogicId>,
1010        reffed_by: &IndexMap<String, Vec<String>>,
1011        eval_data: &mut EvalData,
1012        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1013        mut hidden_fields: Vec<String>,
1014        queue: &mut Vec<(String, bool)>,
1015        result: &mut Vec<Value>,
1016    ) {
1017        while let Some(hf) = hidden_fields.pop() {
1018            let data_path = path_utils::normalize_to_json_pointer(&hf)
1019                .replace("/properties/", "/")
1020                .trim_start_matches('#')
1021                .to_string();
1022
1023            // clear data
1024            eval_data.set(&data_path, Value::Null);
1025            eval_cache.bump_data_version(&data_path);
1026
1027            // Create dependent object for result
1028            let mut change_obj = serde_json::Map::new();
1029            change_obj.insert(
1030                "$ref".to_string(),
1031                Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1032            );
1033            change_obj.insert("$hidden".to_string(), Value::Bool(true));
1034            change_obj.insert("clear".to_string(), Value::Bool(true));
1035            result.push(Value::Object(change_obj));
1036
1037            // Add to queue for standard dependent processing
1038            queue.push((hf.clone(), true));
1039
1040            // Check reffed_by to find other fields that might become hidden
1041            if let Some(referencing_fields) = reffed_by.get(&data_path) {
1042                for rb in referencing_fields {
1043                    // Evaluate condition.hidden for rb
1044                    // We need a way to run specific evaluation?
1045                    // We can check if rb has a hidden evaluation in self.evaluations
1046                    let hidden_eval_key = format!("{}/condition/hidden", rb);
1047
1048                    if let Some(logic_id) = evaluations.get(&hidden_eval_key) {
1049                        // Run evaluation
1050                        // Context: $value = current field (rb) value? No, $value usually refers to changed field in deps.
1051                        // But here we are just re-evaluating the rule.
1052                        // In JS logic: "const result = hiddenFn(runnerCtx);"
1053                        // runnerCtx has the updated data (we just set hf to null).
1054
1055                        let rb_data_path = path_utils::normalize_to_json_pointer(rb)
1056                            .replace("/properties/", "/")
1057                            .trim_start_matches('#')
1058                            .to_string();
1059                        let rb_value = eval_data
1060                            .data()
1061                            .pointer(&rb_data_path)
1062                            .cloned()
1063                            .unwrap_or(Value::Null);
1064
1065                        // We can use engine.run w/ eval_data
1066                        if let Ok(Value::Bool(is_hidden)) = engine.run(logic_id, eval_data.data()) {
1067                            if is_hidden {
1068                                // Check if rb is not already in hidden_fields and has value
1069                                // rb is &String, hidden_fields is Vec<String>
1070                                if !hidden_fields.contains(rb) {
1071                                    let has_value = rb_value != Value::Null && rb_value != "";
1072                                    if has_value {
1073                                        hidden_fields.push(rb.clone());
1074                                    }
1075                                }
1076                            }
1077                        }
1078                    }
1079                }
1080            }
1081        }
1082    }
1083
1084    /// Process the dependents queue.
1085    /// Collects every data path written into `eval_data` into `invalidated_paths`.
1086    pub(crate) fn process_dependents_queue(
1087        engine: &RLogic,
1088        evaluations: &IndexMap<String, LogicId>,
1089        eval_data: &mut EvalData,
1090        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1091        dependents_evaluations: &IndexMap<String, Vec<DependentItem>>,
1092        dep_formula_triggers: &IndexMap<String, Vec<String>>,
1093        evaluated_schema: &Value,
1094        queue: &mut Vec<(String, bool)>,
1095        processed: &mut IndexSet<String>,
1096        result: &mut Vec<Value>,
1097        token: Option<&CancellationToken>,
1098        canceled_paths: Option<&mut Vec<String>>,
1099    ) -> Result<(), String> {
1100        while let Some((current_path, is_transitive)) = queue.pop() {
1101            if let Some(t) = token {
1102                if t.is_cancelled() {
1103                    // Accumulate canceled paths if buffer provided
1104                    if let Some(cp) = canceled_paths {
1105                        cp.push(current_path.clone());
1106                        // Also push remaining items in queue?
1107                        // The user request says "accumulate canceled path if provided", usually implies what was actively cancelled
1108                        // or what was pending. Since we pop one by one, we can just dump the queue back or just push pending.
1109                        // But since we just popped `current_path`, it is the one being cancelled on.
1110                        // Let's also drain the queue.
1111                        for (path, _) in queue.iter() {
1112                            cp.push(path.clone());
1113                        }
1114                    }
1115                    return Err("Cancelled".to_string());
1116                }
1117            }
1118            if processed.contains(&current_path) {
1119                continue;
1120            }
1121            processed.insert(current_path.clone());
1122
1123            // Get the value of the changed field for $value context
1124            let current_data_path = path_utils::normalize_to_json_pointer(&current_path)
1125                .replace("/properties/", "/")
1126                .trim_start_matches('#')
1127                .to_string();
1128            let mut current_value = eval_data
1129                .data()
1130                .pointer(&current_data_path)
1131                .cloned()
1132                .unwrap_or(Value::Null);
1133
1134            // Re-enqueue source fields whose dependent formulas reference this changed field.
1135            // These are fields that have a dependent formula that checks `current_path` as a
1136            // contextual condition (e.g., ins_occ's formula for ph_occupation checks phins_relation).
1137            // When `current_path` changes, we need to re-evaluate those source fields' dependents.
1138            if let Some(formula_sources) = dep_formula_triggers.get(&current_data_path) {
1139                for source_schema_path in formula_sources {
1140                    if !processed.contains(source_schema_path) {
1141                        queue.push((source_schema_path.clone(), true));
1142                    }
1143                }
1144            }
1145
1146            // Find dependents for this path
1147            if let Some(dependent_items) = dependents_evaluations.get(&current_path) {
1148                for dep_item in dependent_items {
1149                    let ref_path = &dep_item.ref_path;
1150                    let pointer_path = path_utils::normalize_to_json_pointer(ref_path);
1151                    // Data paths don't include /properties/, strip it for data access
1152                    let data_path = pointer_path.replace("/properties/", "/");
1153
1154                    let current_ref_value = eval_data
1155                        .data()
1156                        .pointer(&data_path)
1157                        .cloned()
1158                        .unwrap_or(Value::Null);
1159
1160                    // Get field and parent field from schema
1161                    let field = evaluated_schema.pointer(&pointer_path).cloned();
1162
1163                    // Get parent field - skip /properties/ to get actual parent object
1164                    let parent_path = if let Some(last_slash) = pointer_path.rfind("/properties") {
1165                        &pointer_path[..last_slash]
1166                    } else {
1167                        "/"
1168                    };
1169                    let mut parent_field = if parent_path.is_empty() || parent_path == "/" {
1170                        evaluated_schema.clone()
1171                    } else {
1172                        evaluated_schema
1173                            .pointer(parent_path)
1174                            .cloned()
1175                            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
1176                    };
1177
1178                    // omit properties to minimize size of parent field
1179                    if let Value::Object(ref mut map) = parent_field {
1180                        map.remove("properties");
1181                        map.remove("$layout");
1182                    }
1183
1184                    let mut change_obj = serde_json::Map::new();
1185                    change_obj.insert(
1186                        "$ref".to_string(),
1187                        Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1188                    );
1189                    if let Some(f) = field {
1190                        change_obj.insert("$field".to_string(), f);
1191                    }
1192                    change_obj.insert("$parentField".to_string(), parent_field);
1193                    change_obj.insert("transitive".to_string(), Value::Bool(is_transitive));
1194
1195                    // Skip writing back to a field that has already been processed.
1196                    // This prevents formula-triggered re-enqueues from creating circular writes:
1197                    // e.g., ins_gender → triggers phins_relation (via dep_formula_triggers) →
1198                    // phins_relation has a dep that writes back to ins_gender → we must not let that happen.
1199                    if processed.contains(ref_path) {
1200                        continue;
1201                    }
1202
1203                    let mut add_transitive = false;
1204                    let mut add_deps = false;
1205                    // Process clear
1206                    if let Some(clear_val) = &dep_item.clear {
1207                        let should_clear = Self::evaluate_dependent_value_static(
1208                            engine,
1209                            evaluations,
1210                            eval_data,
1211                            clear_val,
1212                            &current_value,
1213                            &current_ref_value,
1214                        )?;
1215                        let clear_bool = match should_clear {
1216                            Value::Bool(b) => b,
1217                            _ => false,
1218                        };
1219
1220                        if clear_bool {
1221                            if data_path == current_data_path {
1222                                current_value = Value::Null;
1223                            }
1224                            eval_data.set(&data_path, Value::Null);
1225                            eval_cache.bump_data_version(&data_path);
1226                            change_obj.insert("clear".to_string(), Value::Bool(true));
1227                            add_transitive = true;
1228                            add_deps = true;
1229                        }
1230                    }
1231
1232                    // Process value
1233                    if let Some(value_val) = &dep_item.value {
1234                        let computed_value = Self::evaluate_dependent_value_static(
1235                            engine,
1236                            evaluations,
1237                            eval_data,
1238                            value_val,
1239                            &current_value,
1240                            &current_ref_value,
1241                        )?;
1242                        let cleaned_val = clean_float_noise_scalar(computed_value);
1243
1244                        let is_clear =
1245                            cleaned_val == Value::Null || cleaned_val.as_str() == Some("");
1246                        let ref_is_clear = current_ref_value == Value::Null
1247                            || current_ref_value.as_str() == Some("");
1248
1249                        if is_clear && !ref_is_clear {
1250                            // Schema value resolved to null or empty string — treat as explicit clear so the
1251                            // consumer receives `null` instead of seeing `undefined`.
1252                            if data_path == current_data_path {
1253                                current_value = Value::Null;
1254                            }
1255                            eval_data.set(&data_path, Value::Null);
1256                            eval_cache.bump_data_version(&data_path);
1257                            change_obj.insert("clear".to_string(), Value::Bool(true));
1258                            add_transitive = true;
1259                            add_deps = true;
1260                        } else if cleaned_val != current_ref_value && !is_clear {
1261                            if data_path == current_data_path {
1262                                current_value = cleaned_val.clone();
1263                            }
1264                            eval_data.set(&data_path, cleaned_val.clone());
1265                            eval_cache.bump_data_version(&data_path);
1266                            change_obj.insert("value".to_string(), cleaned_val);
1267                            add_transitive = true;
1268                            add_deps = true;
1269                        }
1270                    }
1271
1272                    // add only when has clear / value
1273                    if add_deps {
1274                        result.push(Value::Object(change_obj));
1275                    }
1276
1277                    // Add this dependent to queue for transitive processing
1278                    if add_transitive {
1279                        queue.push((ref_path.clone(), true));
1280                    }
1281                }
1282            }
1283        }
1284        Ok(())
1285    }
1286}
1287
1288/// Extract the field key from a subform path.
1289///
1290/// Examples:
1291/// - `#/riders`                               → `riders`
1292/// - `#/properties/form/properties/riders`    → `riders`
1293/// - `#/items`                                → `items`
1294fn subform_field_key(subform_path: &str) -> String {
1295    // Strip leading `#/`
1296    let stripped = subform_path.trim_start_matches('#').trim_start_matches('/');
1297
1298    // The last non-"properties" segment is the field key
1299    stripped
1300        .split('/')
1301        .filter(|seg| !seg.is_empty() && *seg != "properties")
1302        .last()
1303        .unwrap_or(stripped)
1304        .to_string()
1305}