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::time_block;
10use crate::utils::clean_float_noise_scalar;
11use crate::EvalData;
12
13use indexmap::{IndexMap, IndexSet};
14use serde_json::Value;
15
16impl JSONEval {
17    /// Evaluate fields that depend on a changed path.
18    /// Processes all dependent fields transitively, then optionally performs a full
19    /// re-evaluation pass (for read-only / hide effects) and cascades into subforms.
20    pub fn evaluate_dependents(
21        &mut self,
22        changed_paths: &[String],
23        data: Option<&str>,
24        context: Option<&str>,
25        re_evaluate: bool,
26        token: Option<&CancellationToken>,
27        mut canceled_paths: Option<&mut Vec<String>>,
28        include_subforms: bool,
29    ) -> Result<Value, String> {
30        if let Some(t) = token {
31            if t.is_cancelled() {
32                return Err("Cancelled".to_string());
33            }
34        }
35        let _lock = self.eval_lock.lock().unwrap();
36        let mut structural_change_data = None;
37
38        // Update data if provided, diff versions
39        if let Some(data_str) = data {
40            let data_value = json_parser::parse_json_str(data_str)?;
41            let context_value = if let Some(ctx) = context {
42                json_parser::parse_json_str(ctx)?
43            } else {
44                Value::Object(serde_json::Map::new())
45            };
46            let old_data = self.eval_data.snapshot_data_clone();
47            time_block!("  [dep] data_replace_and_context", {
48                self.eval_data
49                    .replace_data_and_context(data_value, context_value);
50            });
51            let new_data = self.eval_data.snapshot_data_clone();
52            time_block!("  [dep] data_diff_versions", {
53                self.eval_cache
54                    .store_snapshot_and_diff_versions(&old_data, &new_data);
55            });
56            structural_change_data = Some((old_data, new_data));
57        }
58
59        // Drop the lock before calling sub-methods that need &mut self
60        drop(_lock);
61
62        // When a subform array changes structurally (riders added/removed/reordered),
63        // evict stale T2 global cache entries whose dep paths use the subform-local key
64        // format that is never bumped by the parent-level diff.
65        if let Some((old_data, new_data)) = structural_change_data {
66            time_block!("  [dep] invalidate_subform_structural", {
67                self.invalidate_subform_caches_on_structural_change(&old_data, &new_data);
68            });
69        }
70
71        let mut result = Vec::new();
72        let mut processed = std::collections::HashMap::new();
73        let mut to_process: Vec<(String, bool, Option<Vec<usize>>)> = changed_paths
74            .iter()
75            .map(|path| {
76                (
77                    path_utils::dot_notation_to_schema_pointer(path),
78                    false,
79                    None,
80                )
81            })
82            .collect();
83
84        time_block!("  [dep] process_dependents_queue", {
85            Self::process_dependents_queue(
86                &self.engine,
87                &self.evaluations,
88                &mut self.eval_data,
89                &mut self.eval_cache,
90                &self.dependents_evaluations,
91                &self.dep_formula_triggers,
92                &self.evaluated_schema,
93                &mut to_process,
94                &mut processed,
95                &mut result,
96                token,
97                canceled_paths.as_mut().map(|v| &mut **v),
98            )?;
99        });
100
101        if re_evaluate {
102            time_block!("  [dep] run_re_evaluate_pass", {
103                self.run_re_evaluate_pass(
104                    token,
105                    &mut to_process,
106                    &mut processed,
107                    &mut result,
108                    canceled_paths.as_mut().map(|v| &mut **v),
109                )?;
110            });
111        }
112
113        if include_subforms {
114            // Augment changed_paths with every subform item field already written into result
115            // by the dependents queue and re-evaluate pass. Without this, when a main-form
116            // dependent rule writes to e.g. `riders.0.benefit`, that path never appears in
117            // `item_changed_paths` inside run_subform_pass → the item is skipped → the
118            // subform item's own `benefit.dependents` never fire.
119            let extended_paths: Vec<String> = {
120                let mut paths = changed_paths.to_vec();
121                for item in &result {
122                    if let Some(ref_val) = item.get("$ref").and_then(|v| v.as_str()) {
123                        let s = ref_val.to_string();
124                        if !paths.contains(&s) {
125                            paths.push(s);
126                        }
127                    }
128                }
129                paths
130            };
131            let subform_invalidated_tables = time_block!("  [dep] run_subform_pass", {
132                self.run_subform_pass(
133                    &extended_paths,
134                    changed_paths,
135                    re_evaluate,
136                    token,
137                    &mut result,
138                )
139            })?;
140
141            // If the subform pass invalidated and re-evaluated any T2 $params tables that
142            // depend on subform-item fields (e.g. RIDER_ZLOS_TABLE), those tables now have fresh
143            // rows in the T2 cache, but the parent's evaluated_schema still contains the stale
144            // pre-dependents values written by the initial run_re_evaluate_pass. Run a second
145            // evaluate_internal on the parent so that downstream $params tables like
146            // RIDER_FIRST_PREM_PER_PAY_TABLE are re-evaluated against the new T2 rows and the
147            // parent's evaluated_schema reflects the correct post-dependents state.
148            if subform_invalidated_tables {
149                let _lock2 = self.eval_lock.lock().unwrap();
150                drop(_lock2);
151                self.evaluate_internal(None, token)?;
152
153                // Run a second subform pass so each item re-evaluates computed readonly fields
154                // (e.g. first_prem) against the now-fresh T2 tables. The first subform pass ran
155                // before evaluate_internal refreshed those tables. Deduplication (last-writer-wins)
156                // ensures these up-to-date entries overwrite any stale entries from pass 1.
157                self.run_subform_pass(&[], &[], true, token, &mut result)?;
158
159                // Patch the whole-array entry in result with the post-pass eval_data snapshot.
160                for (subform_path, _) in &self.subforms {
161                    let data_ptr = path_utils::schema_path_to_data_pointer(subform_path);
162                    let data_ptr_str = data_ptr.to_string();
163                    let dot_path = data_ptr_str.trim_start_matches('/').replace('/', ".");
164
165                    if let Some(fresh_val) = self.eval_data.get(&data_ptr_str) {
166                        let mut patched = false;
167                        for item in result.iter_mut() {
168                            if item
169                                .get("$ref")
170                                .and_then(|r| r.as_str())
171                                .map(|r| r == dot_path)
172                                .unwrap_or(false)
173                            {
174                                if let Some(map) = item.as_object_mut() {
175                                    map.remove("clear");
176                                    map.insert("value".to_string(), fresh_val.clone());
177                                }
178                                patched = true;
179                                break;
180                            }
181                        }
182                        if !patched {
183                            let mut obj = serde_json::Map::new();
184                            obj.insert("$ref".to_string(), serde_json::Value::String(dot_path));
185                            obj.insert("value".to_string(), fresh_val.clone());
186                            result.push(serde_json::Value::Object(obj));
187                        }
188                    }
189                }
190            }
191        }
192
193        // Deduplicate by $ref — keep the last entry for each path.
194        // Multiple passes (dependents queue, re-evaluate, subform) may independently emit
195        // the same $ref when cache versions cause overlapping detections. The subform pass
196        // result is most specific and wins because it is appended last.
197        let deduped = {
198            let mut seen: IndexMap<String, usize> = IndexMap::new();
199            for (i, item) in result.iter().enumerate() {
200                if let Some(r) = item.get("$ref").and_then(|v| v.as_str()) {
201                    seen.insert(r.to_string(), i);
202                }
203            }
204            let last_indices: IndexSet<usize> = seen.values().copied().collect();
205            let out: Vec<Value> = result
206                .into_iter()
207                .enumerate()
208                .filter(|(i, _)| last_indices.contains(i))
209                .map(|(_, item)| item)
210                .collect();
211            out
212        };
213
214        // Refresh main_form_snapshot so the next evaluate() call computes diffs from the
215        // post-dependents state instead of the old pre-dependents snapshot.
216        // Without this, evaluate() re-diffs every field that evaluate_dependents already
217        // processed, double-bumping data_versions and causing spurious cache misses in
218        // evaluate_internal (observed as unexpected ~550ms "cache hit" full evaluates).
219        // Only update when no subform item is active — subform evaluate_dependents calls
220        // must not overwrite the parent's snapshot.
221        if self.eval_cache.active_item_index.is_none() {
222            let current_snapshot = self.eval_data.snapshot_data_clone();
223            self.eval_cache.main_form_snapshot = Some(current_snapshot);
224        }
225
226        Ok(Value::Array(deduped))
227    }
228
229    /// Full re-evaluation pass: runs `evaluate_internal`, then applies read-only fixes and
230    /// recursive hide effects, feeding any newly-generated changes back into the dependents queue.
231    fn run_re_evaluate_pass(
232        &mut self,
233        token: Option<&CancellationToken>,
234        to_process: &mut Vec<(String, bool, Option<Vec<usize>>)>,
235        processed: &mut std::collections::HashMap<String, Option<std::collections::HashSet<usize>>>,
236        result: &mut Vec<Value>,
237        mut canceled_paths: Option<&mut Vec<String>>,
238    ) -> Result<(), String> {
239        // --- Schema Default Value Pass (Before Eval) ---
240        self.run_schema_default_value_pass(
241            token,
242            to_process,
243            processed,
244            result,
245            canceled_paths.as_mut().map(|v| &mut **v),
246        )?;
247
248        // Resolve the correct data_versions tracker before snapshotting.
249        // When active_item_index is Some(idx), evaluate_internal bumps
250        // subform_caches[idx].data_versions — NOT the main data_versions.
251        // Using the main tracker for both snapshot and post-eval lookup would make
252        // old_ver == new_ver always, so no changed values would ever be emitted.
253        let pre_eval_versions = if let Some(idx) = self.eval_cache.active_item_index {
254            self.eval_cache
255                .subform_caches
256                .get(&idx)
257                .map(|c| c.data_versions.clone())
258                .unwrap_or_else(|| self.eval_cache.data_versions.clone())
259        } else {
260            self.eval_cache.data_versions.clone()
261        };
262
263        self.evaluate_internal(None, token)?;
264
265        // Emit result entries for every sorted-evaluation whose version uniquely bumped.
266        let active_idx = self.eval_cache.active_item_index;
267        for eval_key in self.sorted_evaluations.iter().flatten() {
268            if eval_key.contains("/$params/") || eval_key.contains("/$") {
269                continue;
270            }
271
272            let schema_ptr = path_utils::schema_path_to_data_pointer(eval_key);
273            let data_path = schema_ptr.trim_start_matches('/').to_string();
274
275            let version_path = format!("/{}", data_path);
276            let old_ver = pre_eval_versions.get(&version_path);
277            let new_ver = if let Some(idx) = active_idx {
278                self.eval_cache
279                    .subform_caches
280                    .get(&idx)
281                    .map(|c| c.data_versions.get(&version_path))
282                    .unwrap_or_else(|| self.eval_cache.data_versions.get(&version_path))
283            } else {
284                self.eval_cache.data_versions.get(&version_path)
285            };
286
287            if new_ver > old_ver {
288                if let Some(new_val) = self.evaluated_schema.pointer(&schema_ptr) {
289                    let dot_path = data_path.trim_end_matches("/value").replace('/', ".");
290                    let mut obj = serde_json::Map::new();
291                    obj.insert("$ref".to_string(), Value::String(dot_path));
292                    let is_clear = new_val == &Value::Null || new_val.as_str() == Some("");
293                    if is_clear {
294                        obj.insert("clear".to_string(), Value::Bool(true));
295                    } else {
296                        obj.insert("value".to_string(), new_val.clone());
297                    }
298                    result.push(Value::Object(obj));
299                }
300            }
301        }
302
303        // --- Read-Only Pass ---
304        let mut readonly_changes = Vec::new();
305        let mut readonly_values = Vec::new();
306        for path in self.conditional_readonly_fields.iter() {
307            let normalized = path_utils::normalize_to_json_pointer(path);
308            if let Some(schema_el) = self.evaluated_schema.pointer(&normalized) {
309                self.check_readonly_for_dependents(
310                    schema_el,
311                    path,
312                    &mut readonly_changes,
313                    &mut readonly_values,
314                );
315            }
316        }
317        // Captured before draining into to_process — !to_process.is_empty() would also
318        // fire for entries from earlier passes, triggering a spurious second evaluate_internal.
319        let had_actual_readonly_changes = !readonly_changes.is_empty();
320
321        // Subform root arrays need special treatment: writing the full schema array into
322        // eval_data would overwrite computed nested fields (e.g. loading_benefit.first_prem)
323        // with the stale snapshot from evaluated_schema (computed before the T2 table refresh).
324        // Instead, merge only primitive/scalar item fields (sa, code, prem_pay_period, etc.),
325        // leaving nested objects intact so run_subform_pass sees the correct old/new diff.
326        let subform_data_paths: std::collections::HashSet<String> = self
327            .subforms
328            .keys()
329            .map(|p| {
330                path_utils::schema_path_to_data_pointer(p)
331                    .replace("/value/", "/")
332                    .to_string()
333            })
334            .collect();
335
336        for (path, schema_value) in readonly_changes {
337            let data_path = path_utils::schema_path_to_data_pointer(&path).replace("/value/", "/");
338
339            if subform_data_paths.contains(&data_path) {
340                // Per-item scalar merge: propagate input fields without touching computed objects.
341                if let (Value::Array(schema_items), Some(Value::Array(existing_items))) = (
342                    &schema_value,
343                    self.eval_data.data().pointer(&data_path).cloned().as_ref(),
344                ) {
345                    let mut merged_items = existing_items.clone();
346                    for (i, schema_item) in schema_items.iter().enumerate() {
347                        if let (Some(existing), Value::Object(schema_map)) =
348                            (merged_items.get_mut(i), schema_item)
349                        {
350                            if let Some(existing_map) = existing.as_object_mut() {
351                                for (k, v) in schema_map {
352                                    if !v.is_object() {
353                                        existing_map.insert(k.clone(), v.clone());
354                                    }
355                                }
356                            }
357                        } else if i >= merged_items.len() {
358                            merged_items.push(schema_item.clone());
359                        }
360                    }
361                    self.eval_data.set(&data_path, Value::Array(merged_items));
362                } else {
363                    self.eval_data.set(&data_path, schema_value.clone());
364                }
365                self.eval_cache.bump_data_version(&data_path);
366                to_process.push((path, true, None));
367                continue;
368            }
369
370            self.eval_data.set(&data_path, schema_value.clone());
371            self.eval_cache.bump_data_version(&data_path);
372            to_process.push((path, true, None));
373        }
374        for (path, schema_value) in readonly_values {
375            let data_path = path_utils::schema_path_to_data_pointer(&path).replace("/value/", "/");
376            let mut obj = serde_json::Map::new();
377            obj.insert(
378                "$ref".to_string(),
379                Value::String(path_utils::pointer_to_dot_notation(&data_path)),
380            );
381            obj.insert("$readonly".to_string(), Value::Bool(true));
382            let is_clear = schema_value == Value::Null || schema_value.as_str() == Some("");
383            if is_clear {
384                obj.insert("clear".to_string(), Value::Bool(true));
385            } else {
386                obj.insert("value".to_string(), schema_value);
387            }
388            result.push(Value::Object(obj));
389        }
390
391        // A readonly write can change input to a cached `$params` table. Refresh only when
392        // one of those tables depends on a field written by this pass. `to_process` stores
393        // schema paths with `#`; dependency metadata stores the same paths without it.
394        // Do not use `!to_process.is_empty()`: entries from earlier passes would cause an
395        // unnecessary full evaluation even when this pass wrote no relevant readonly field.
396        if had_actual_readonly_changes {
397            let readonly_dep_prefixes: Vec<String> = to_process
398                .iter()
399                .map(|(path, _, _)| path.trim_start_matches('#').to_string())
400                .collect();
401            let params_table_keys: Vec<String> = self
402                .table_metadata
403                .keys()
404                .filter(|key| key.starts_with("#/$params"))
405                .filter(|key| {
406                    self.dependencies
407                        .get(*key)
408                        .map(|deps| {
409                            deps.iter().any(|dep| {
410                                readonly_dep_prefixes.iter().any(|readonly| {
411                                    dep == readonly
412                                        || dep
413                                            .strip_prefix(readonly)
414                                            .is_some_and(|suffix| suffix.starts_with('/'))
415                                })
416                            })
417                        })
418                        .unwrap_or(false)
419                })
420                .cloned()
421                .collect();
422
423            if !params_table_keys.is_empty() {
424                if let Some(active_idx) = self.eval_cache.active_item_index {
425                    self.eval_cache
426                        .invalidate_params_tables_for_item(active_idx, &params_table_keys);
427                }
428                self.evaluate_internal(None, token)?;
429            }
430        }
431
432        if !to_process.is_empty() {
433            Self::process_dependents_queue(
434                &self.engine,
435                &self.evaluations,
436                &mut self.eval_data,
437                &mut self.eval_cache,
438                &self.dependents_evaluations,
439                &self.dep_formula_triggers,
440                &self.evaluated_schema,
441                to_process,
442                processed,
443                result,
444                token,
445                canceled_paths.as_mut().map(|v| &mut **v),
446            )?;
447        }
448
449        // --- Recursive Hide Pass ---
450        // Rebuild layout refs from current evaluated_schema. Unlike mutable legacy JS
451        // objects, Rust resolved refs are copies, so inherited visibility must stay
452        // per-run state rather than be written back into the source schema.
453        self.resolve_layout(false)?;
454
455        let mut hidden_fields = Vec::new();
456        for path in self.conditional_hidden_fields.iter() {
457            let normalized = path_utils::normalize_to_json_pointer(path);
458            if let Some(schema_el) = self.evaluated_schema.pointer(&normalized) {
459                self.check_hidden_field(schema_el, path, &mut hidden_fields);
460            }
461        }
462        for path in self.layout_condition_hidden_refs.iter() {
463            if let Some(schema_el) = self.evaluated_schema.pointer(path) {
464                self.check_effectively_hidden_field(schema_el, path, &mut hidden_fields);
465            }
466        }
467        hidden_fields.sort();
468        hidden_fields.dedup();
469        if !hidden_fields.is_empty() {
470            Self::recursive_hide_effect(
471                &self.engine,
472                &self.evaluations,
473                &self.reffed_by,
474                &mut self.eval_data,
475                &mut self.eval_cache,
476                hidden_fields,
477                to_process,
478                result,
479            );
480        }
481        if !to_process.is_empty() {
482            Self::process_dependents_queue(
483                &self.engine,
484                &self.evaluations,
485                &mut self.eval_data,
486                &mut self.eval_cache,
487                &self.dependents_evaluations,
488                &self.dep_formula_triggers,
489                &self.evaluated_schema,
490                to_process,
491                processed,
492                result,
493                token,
494                canceled_paths.as_mut().map(|v| &mut **v),
495            )?;
496        }
497
498        Ok(())
499    }
500
501    /// Collect visible primitive schema values missing from input.
502    fn collect_visible_static_defaults(&self) -> Vec<(String, Value, String)> {
503        let mut defaults = Vec::new();
504        let schema_values = self.get_schema_value_array();
505
506        if let Value::Array(values) = schema_values {
507            for item in values {
508                let Value::Object(map) = item else {
509                    continue;
510                };
511                let Some(Value::String(dot_path)) = map.get("path") else {
512                    continue;
513                };
514                let Some(schema_val) = map.get("value") else {
515                    continue;
516                };
517
518                let schema_ptr = path_utils::dot_notation_to_schema_pointer(dot_path);
519                if let Some(Value::Object(schema_node)) = self
520                    .evaluated_schema
521                    .pointer(schema_ptr.trim_start_matches('#'))
522                {
523                    if let Some(Value::Object(condition)) = schema_node.get("condition") {
524                        if let Some(hidden_val) = condition.get("hidden") {
525                            if !hidden_val.is_boolean() || hidden_val.as_bool() == Some(true) {
526                                continue;
527                            }
528                        }
529                    }
530                }
531
532                let data_path = dot_path.replace('.', "/");
533                let current_data = self
534                    .eval_data
535                    .data()
536                    .pointer(&format!("/{}", data_path))
537                    .unwrap_or(&Value::Null);
538                let is_empty = matches!(current_data, Value::Null)
539                    || matches!(current_data, Value::String(s) if s.is_empty());
540                let is_schema_val_empty = matches!(schema_val, Value::Null)
541                    || matches!(schema_val, Value::String(s) if s.is_empty())
542                    || matches!(schema_val, Value::Object(map) if map.contains_key("$evaluation"));
543
544                if is_empty && !is_schema_val_empty && current_data != schema_val {
545                    defaults.push((data_path, schema_val.clone(), dot_path.clone()));
546                }
547            }
548        }
549
550        defaults
551    }
552
553    pub(crate) fn apply_visible_static_defaults(&mut self) -> bool {
554        let defaults = self.collect_visible_static_defaults();
555        for (data_path, schema_val, _) in &defaults {
556            self.eval_data
557                .set(&format!("/{}", data_path), schema_val.clone());
558            self.eval_cache
559                .bump_data_version(&format!("/{}", data_path));
560        }
561        !defaults.is_empty()
562    }
563
564    /// Internal method to run the schema default value pass.
565    /// Filters for only primitive schema values (not $evaluation objects).
566    fn run_schema_default_value_pass(
567        &mut self,
568        token: Option<&CancellationToken>,
569        to_process: &mut Vec<(String, bool, Option<Vec<usize>>)>,
570        processed: &mut std::collections::HashMap<String, Option<std::collections::HashSet<usize>>>,
571        result: &mut Vec<Value>,
572        mut canceled_paths: Option<&mut Vec<String>>,
573    ) -> Result<(), String> {
574        let mut default_value_changes = Vec::new();
575        let schema_values = self.get_schema_value_array();
576
577        if let Value::Array(values) = schema_values {
578            for item in values {
579                if let Value::Object(map) = item {
580                    if let (Some(Value::String(dot_path)), Some(schema_val)) =
581                        (map.get("path"), map.get("value"))
582                    {
583                        let schema_ptr = path_utils::dot_notation_to_schema_pointer(dot_path);
584                        if let Some(Value::Object(schema_node)) = self
585                            .evaluated_schema
586                            .pointer(schema_ptr.trim_start_matches('#'))
587                        {
588                            if let Some(Value::Object(condition)) = schema_node.get("condition") {
589                                if let Some(hidden_val) = condition.get("hidden") {
590                                    // Skip if hidden is true OR if it's a non-primitive value (formula object)
591                                    if !hidden_val.is_boolean()
592                                        || hidden_val.as_bool() == Some(true)
593                                    {
594                                        continue;
595                                    }
596                                }
597                            }
598                        }
599
600                        let data_path = dot_path.replace('.', "/");
601                        let current_data = self
602                            .eval_data
603                            .data()
604                            .pointer(&format!("/{}", data_path))
605                            .unwrap_or(&Value::Null);
606
607                        let is_empty = match current_data {
608                            Value::Null => true,
609                            Value::String(s) if s.is_empty() => true,
610                            _ => false,
611                        };
612
613                        let is_schema_val_empty = match schema_val {
614                            Value::Null => true,
615                            Value::String(s) if s.is_empty() => true,
616                            Value::Object(map) if map.contains_key("$evaluation") => true,
617                            _ => false,
618                        };
619
620                        if is_empty && !is_schema_val_empty && current_data != schema_val {
621                            default_value_changes.push((
622                                data_path,
623                                schema_val.clone(),
624                                dot_path.clone(),
625                            ));
626                        }
627                    }
628                }
629            }
630        }
631
632        let mut has_changes = false;
633        for (data_path, schema_val, dot_path) in default_value_changes {
634            self.eval_data
635                .set(&format!("/{}", data_path), schema_val.clone());
636            self.eval_cache
637                .bump_data_version(&format!("/{}", data_path));
638
639            let mut change_obj = serde_json::Map::new();
640            change_obj.insert("$ref".to_string(), Value::String(dot_path));
641            let is_clear = schema_val == Value::Null || schema_val.as_str() == Some("");
642            if is_clear {
643                change_obj.insert("clear".to_string(), Value::Bool(true));
644            } else {
645                change_obj.insert("value".to_string(), schema_val);
646            }
647            result.push(Value::Object(change_obj));
648
649            let schema_ptr = format!("#/{}", data_path.replace('/', "/properties/"));
650            to_process.push((schema_ptr, true, None));
651            has_changes = true;
652        }
653
654        if has_changes {
655            Self::process_dependents_queue(
656                &self.engine,
657                &self.evaluations,
658                &mut self.eval_data,
659                &mut self.eval_cache,
660                &self.dependents_evaluations,
661                &self.dep_formula_triggers,
662                &self.evaluated_schema,
663                to_process,
664                processed,
665                result,
666                token,
667                canceled_paths.as_mut().map(|v| &mut **v),
668            )?;
669        }
670
671        Ok(())
672    }
673
674    /// Cascade dependency evaluation into each subform item.
675    ///
676    /// For every registered subform, this method iterates over its array items and runs
677    /// `evaluate_dependents` on the subform using the cache-swap strategy so the subform
678    /// can see global main-form Tier 2 cache entries (avoiding redundant table re-evaluation).
679    ///
680    /// `sub_re_evaluate` is set **only** when the parent's bumped `data_versions` intersect
681    /// with paths the subform actually depends on — preventing expensive full re-evals on
682    /// subform items whose dependencies did not change.
683    fn run_subform_pass(
684        &mut self,
685        changed_paths: &[String],
686        parent_changed_paths: &[String],
687        _re_evaluate: bool,
688        token: Option<&CancellationToken>,
689        result: &mut Vec<Value>,
690    ) -> Result<bool, String> {
691        let mut any_table_invalidated = false;
692        // Collect subform paths once (avoids holding borrow on self.subforms during mutation)
693        let subform_paths: Vec<String> = self.subforms.keys().cloned().collect();
694
695        for subform_path in subform_paths {
696            let field_key = subform_field_key(&subform_path);
697            // Compute dotted path and prefix strings once per subform, not per item
698            let subform_dot_path =
699                path_utils::pointer_to_dot_notation(&subform_path).replace(".properties.", ".");
700            let field_prefix = format!("{}.", field_key);
701            let subform_ptr = normalize_to_json_pointer(&subform_path);
702
703            // Borrow only the item count first — avoid cloning the full array
704            let item_count =
705                get_value_by_pointer_without_properties(self.eval_data.data(), &subform_ptr)
706                    .and_then(|v| v.as_array())
707                    .map(|a| a.len())
708                    .unwrap_or(0);
709
710            if item_count == 0 {
711                continue;
712            }
713
714            // Evict stale per-item caches for indices that no longer exist in the array.
715            // This prevents memory leaks when riders are removed and the array shrinks.
716            self.eval_cache.prune_subform_caches(item_count);
717
718            // Snapshot the parent's version trackers once, before iterating any riders.
719            // Using the live `parent_cache.data_versions` inside the loop would let rider N's
720            // evaluation bumps contaminate the merge_from baseline for rider M (M ≠ N),
721            // causing cache misses and wrong re-evaluations on subsequent visits to rider M.
722            let parent_data_versions_snapshot = self.eval_cache.data_versions.clone();
723            let parent_params_versions_snapshot = self.eval_cache.params_versions.clone();
724
725            for idx in 0..item_count {
726                // Map absolute changed paths → subform-internal paths for this item index
727                let prefix_dot = format!("{}.{}.", subform_dot_path, idx);
728                let prefix_bracket = format!("{}[{}].", subform_dot_path, idx);
729                let prefix_field_bracket = format!("{}[{}].", field_key, idx);
730
731                let item_changed_paths: Vec<String> = changed_paths
732                    .iter()
733                    .filter_map(|p| {
734                        if p.starts_with(&prefix_bracket) {
735                            Some(p.replacen(&prefix_bracket, &field_prefix, 1))
736                        } else if p.starts_with(&prefix_dot) {
737                            Some(p.replacen(&prefix_dot, &field_prefix, 1))
738                        } else if p.starts_with(&prefix_field_bracket) {
739                            Some(p.replacen(&prefix_field_bracket, &field_prefix, 1))
740                        } else {
741                            None
742                        }
743                    })
744                    .collect();
745
746                // Build minimal merged data: clone only item at idx, share $params shallowly.
747                // This avoids cloning the full 5MB parent payload for every item.
748                let item_val =
749                    get_value_by_pointer_without_properties(self.eval_data.data(), &subform_ptr)
750                        .and_then(|v| v.as_array())
751                        .and_then(|a| a.get(idx))
752                        .cloned()
753                        .unwrap_or(Value::Null);
754
755                // Build a minimal parent object with only the fields the subform needs:
756                // the item under field_key, plus all non-array top-level parent fields
757                // ($params markers, scalars). Large arrays are already stripped to static_arrays.
758                let merged_data = {
759                    let parent = self.eval_data.data();
760                    let mut map = serde_json::Map::new();
761                    if let Value::Object(parent_map) = parent {
762                        for (k, v) in parent_map {
763                            if k == &field_key {
764                                // Will be overridden with the single item below
765                                continue;
766                            }
767                            // Include scalars, objects ($params markers, etc.) but skip
768                            // other large array fields that aren't this subform
769                            if !v.is_array() {
770                                map.insert(k.clone(), v.clone());
771                            }
772                        }
773                    }
774                    map.insert(field_key.clone(), item_val.clone());
775                    Value::Object(map)
776                };
777
778                // Project parent changes into directly dependent rider value formulas. This is
779                // graph-driven: no product/field path policy belongs in evaluator code. Tables
780                // remain excluded because evaluating a `$params` table with one rider payload can
781                // overwrite shared parent cache rows.
782                let parent_dependency_paths: Vec<String> = parent_changed_paths
783                    .iter()
784                    .map(|path| {
785                        path_utils::dot_notation_to_schema_pointer(path)
786                            .trim_start_matches('#')
787                            .to_string()
788                    })
789                    .collect();
790                let dependent_value_paths: Vec<String> = self
791                    .subforms
792                    .get(&subform_path)
793                    .map(|subform| {
794                        subform
795                            .dependencies
796                            .iter()
797                            .filter(|(key, deps)| {
798                                !subform.table_metadata.contains_key(*key)
799                                    && !key.starts_with("#/$params/")
800                                    && key.ends_with("/value")
801                                    && parent_dependency_paths
802                                        .iter()
803                                        .any(|dependency| deps.contains(dependency))
804                            })
805                            .map(|(key, _)| key.clone())
806                            .collect()
807                    })
808                    .unwrap_or_default();
809
810                if item_changed_paths.is_empty() && !dependent_value_paths.is_empty() {
811                    let mut parent_cache = std::mem::take(&mut self.eval_cache);
812                    parent_cache.set_active_item(idx);
813                    let subform = self
814                        .subforms
815                        .get_mut(&subform_path)
816                        .expect("subform exists");
817                    subform.eval_data.replace_data_and_context(
818                        merged_data,
819                        self.eval_data
820                            .data()
821                            .get("$context")
822                            .cloned()
823                            .unwrap_or(Value::Null),
824                    );
825                    std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
826                    subform.evaluate_internal(Some(&dependent_value_paths), token)?;
827
828                    for formula_path in &dependent_value_paths {
829                        let schema_path = path_utils::normalize_to_json_pointer(formula_path);
830                        let data_path = path_utils::schema_path_to_data_pointer(formula_path);
831                        let Some(value) = subform.evaluated_schema.pointer(&schema_path).cloned()
832                        else {
833                            continue;
834                        };
835                        let mut change = serde_json::Map::new();
836                        let field = data_path
837                            .trim_start_matches('/')
838                            .trim_end_matches("/value")
839                            .replace('/', ".");
840                        let field = field.strip_prefix(&field_prefix).unwrap_or(&field);
841                        change.insert(
842                            "$ref".to_string(),
843                            Value::String(format!("{}.{}.{}", subform_dot_path, idx, field)),
844                        );
845                        if value == Value::Null || value.as_str() == Some("") {
846                            change.insert("clear".to_string(), Value::Bool(true));
847                        } else {
848                            change.insert("value".to_string(), value);
849                        }
850                        result.push(Value::Object(change));
851                    }
852                    std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
853                    parent_cache.clear_active_item();
854                    self.eval_cache = parent_cache;
855                    continue;
856                }
857
858                let Some(subform) = self.subforms.get_mut(&subform_path) else {
859                    continue;
860                };
861
862                // Parent-only re-evaluation already refreshes global `$params` tables and main-form
863                // readonly values. Re-running every subform can evaluate a table with the transient
864                // parent state (for example a cleared `prem_pay_period`) and overwrite fresh global
865                // rows with an empty table. Only item-specific changed paths re-evaluate subforms.
866                let sub_re_evaluate = !item_changed_paths.is_empty();
867                if !sub_re_evaluate && item_changed_paths.is_empty() {
868                    continue;
869                }
870
871                // Prepare cache state for this item
872                self.eval_cache.ensure_active_item_cache(idx);
873                let old_item_val = {
874                    let snapshot = self
875                        .eval_cache
876                        .subform_caches
877                        .get(&idx)
878                        .map(|c| c.item_snapshot.clone())
879                        .unwrap_or(Value::Null);
880
881                    if snapshot == Value::Null {
882                        if let Some(main_snap) = &self.eval_cache.main_form_snapshot {
883                            get_value_by_pointer_without_properties(main_snap, &subform_ptr)
884                                .and_then(|v| v.as_array())
885                                .and_then(|a| a.get(idx))
886                                .cloned()
887                                .unwrap_or(Value::Null)
888                        } else {
889                            Value::Null
890                        }
891                    } else {
892                        snapshot
893                    }
894                };
895
896                subform.eval_data.replace_data_and_context(
897                    merged_data,
898                    self.eval_data
899                        .data()
900                        .get("$context")
901                        .cloned()
902                        .unwrap_or(Value::Null),
903                );
904                let new_item_val = subform
905                    .eval_data
906                    .data()
907                    .get(&field_key)
908                    .cloned()
909                    .unwrap_or(Value::Null);
910
911                // Cache-swap: lend parent cache to subform
912                let mut parent_cache = std::mem::take(&mut self.eval_cache);
913                parent_cache.ensure_active_item_cache(idx);
914
915                // Snapshot item data_versions BEFORE the diff so we can detect which paths
916                // are newly bumped by this specific diff pass (vs historical bumps from prior calls).
917                let pre_diff_item_versions = parent_cache
918                    .subform_caches
919                    .get(&idx)
920                    .map(|c| c.data_versions.clone());
921
922                if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
923                    // Merge all data versions from the parent snapshot. We must include non-$params
924                    // paths so that parent field updates (like wop_basic_benefit changing) correctly
925                    // invalidate subform per-item cache entries that depend on them.
926                    c.data_versions.merge_from(&parent_data_versions_snapshot);
927                    // Always reflect the latest $params (schema-level, index-independent).
928                    c.data_versions
929                        .merge_from_params(&parent_params_versions_snapshot);
930                    crate::jsoneval::eval_cache::diff_and_update_versions(
931                        &mut c.data_versions,
932                        &format!("/{}", field_key),
933                        &old_item_val,
934                        &new_item_val,
935                        "run_subform_pass_diff_and_update_versions",
936                    );
937                    c.item_snapshot = new_item_val;
938                }
939
940                // Propagate paths NEWLY bumped by this diff into parent_cache.data_versions so that
941                // check_table_cache (which validates T2 global entries against self.data_versions)
942                // correctly detects changes to rider fields like `sa` and returns Cache MISS.
943                if let (Some(ref pre), Some(c)) = (
944                    &pre_diff_item_versions,
945                    parent_cache.subform_caches.get(&idx),
946                ) {
947                    let field_prefix_slash = format!("/{}/", field_key);
948                    let newly_bumped: Vec<String> = c
949                        .data_versions
950                        .versions()
951                        .filter(|(k, &v)| k.starts_with(&field_prefix_slash) && v > pre.get(k))
952                        .map(|(k, _)| k.to_string())
953                        .collect();
954                    if !newly_bumped.is_empty() {
955                        for k in newly_bumped {
956                            parent_cache
957                                .data_versions
958                                .bump(&k, "propagate_newly_bumped");
959                        }
960                        parent_cache.eval_generation += 1;
961                    }
962                }
963
964                // Invalidate stale T2 $params table entries whose deps overlap any path newly
965                //
966                // These tables MUST be re-evaluated by the subform engine (not here) because
967                // their formulas read subform-local paths like #/riders/properties/sa which
968                // only resolve correctly when the active item is injected under the riders key.
969                {
970                    let field_prefix_slash = format!("/{}/", field_key);
971                    let newly_bumped_schema_paths: Vec<String> = if let (Some(ref pre), Some(c)) = (
972                        &pre_diff_item_versions,
973                        parent_cache.subform_caches.get(&idx),
974                    ) {
975                        c.data_versions
976                            .versions()
977                            .filter(|(k, &v)| k.starts_with(&field_prefix_slash) && v > pre.get(k))
978                            .map(|(k, _)| {
979                                // Convert data-version path (e.g. /riders/sa) to schema dep
980                                // format (e.g. /riders/properties/sa) for dep matching against
981                                // self.dependencies, which stores paths WITHOUT the '#' prefix.
982                                let sub = k.trim_start_matches(&field_prefix_slash);
983                                format!(
984                                    "/{}/properties/{}",
985                                    field_key,
986                                    sub.replace('/', "/properties/")
987                                )
988                            })
989                            .collect()
990                    } else {
991                        Vec::new()
992                    };
993
994                    if !newly_bumped_schema_paths.is_empty() {
995                        let params_table_keys: Vec<String> = self
996                            .table_metadata
997                            .keys()
998                            .filter(|k| k.starts_with("#/$params"))
999                            .filter(|k| {
1000                                self.dependencies
1001                                    .get(*k)
1002                                    .map(|deps| {
1003                                        deps.iter().any(|dep| {
1004                                            newly_bumped_schema_paths
1005                                                .iter()
1006                                                .any(|b| dep == b || dep.starts_with(b.as_str()))
1007                                        })
1008                                    })
1009                                    .unwrap_or(false)
1010                            })
1011                            .cloned()
1012                            .collect();
1013
1014                        if !params_table_keys.is_empty() {
1015                            parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
1016                            any_table_invalidated = true;
1017                        }
1018                    }
1019                }
1020
1021                parent_cache.set_active_item(idx);
1022                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
1023
1024                let subform_result = time_block!("    [subform_pass] rider evaluate_dependents", {
1025                    subform.evaluate_dependents(
1026                        &item_changed_paths,
1027                        None,
1028                        None,
1029                        sub_re_evaluate,
1030                        token,
1031                        None,
1032                        false,
1033                    )
1034                });
1035
1036                // Restore parent cache
1037                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
1038                parent_cache.clear_active_item();
1039
1040                // Propagate the updated item_snapshot from the parent's T1 cache into the
1041                // subform's own eval_cache. Without this, subsequent evaluate_subform() calls
1042                // for this idx read the OLD snapshot (pre-run_subform_pass) and see a diff
1043                // against the new data → item_paths_bumped = true → spurious table invalidation.
1044                if let Some(parent_item_cache) = self.eval_cache.subform_caches.get(&idx) {
1045                    let snapshot = parent_item_cache.item_snapshot.clone();
1046                    subform.eval_cache.ensure_active_item_cache(idx);
1047                    if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
1048                        sub_cache.item_snapshot = snapshot;
1049                    }
1050                }
1051
1052                self.eval_cache = parent_cache;
1053
1054                if let Ok(Value::Array(changes)) = subform_result {
1055                    let mut had_any_change = false;
1056                    for change in changes {
1057                        if let Some(obj) = change.as_object() {
1058                            if let Some(Value::String(ref_path)) = obj.get("$ref") {
1059                                // Remap the $ref path to include the parent path + item index
1060                                let new_ref = if ref_path.starts_with(&field_prefix) {
1061                                    format!(
1062                                        "{}.{}.{}",
1063                                        subform_dot_path,
1064                                        idx,
1065                                        &ref_path[field_prefix.len()..]
1066                                    )
1067                                } else {
1068                                    format!("{}.{}.{}", subform_dot_path, idx, ref_path)
1069                                };
1070
1071                                // Write the computed value back to parent eval_data so subsequent
1072                                // evaluate_subform calls see an up-to-date old_item_snapshot.
1073                                // Without this, the diff in with_item_cache_swap sees stale parent
1074                                // data vs the new call's apply_changes values → spurious item bumps
1075                                // → invalidate_params_tables_for_item fires → eval_generation bumps.
1076                                if let Some(val) = obj.get("value") {
1077                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
1078                                    self.eval_data.set(&data_ptr, val.clone());
1079                                    had_any_change = true;
1080                                } else if obj.get("clear").and_then(Value::as_bool) == Some(true) {
1081                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
1082                                    self.eval_data.set(&data_ptr, Value::Null);
1083                                    had_any_change = true;
1084                                }
1085
1086                                let mut new_obj = obj.clone();
1087                                new_obj.insert("$ref".to_string(), Value::String(new_ref));
1088                                result.push(Value::Object(new_obj));
1089                            } else {
1090                                // No $ref rewrite needed — push as-is without cloning the map
1091                                result.push(change);
1092                            }
1093                        }
1094                    }
1095
1096                    // After writing computed outputs (first_prem, wop_rider_premi, etc.) back to
1097                    // parent eval_data, refresh the item_snapshot so that subsequent evaluate_subform
1098                    // calls see the post-computation state as their baseline. Without this, the
1099                    // snapshot only contains the raw input (before apply_changes), so the next
1100                    // with_item_cache_swap diff detects ALL computed fields (first_prem, code, sa ...)
1101                    // as "changed" even when only genuinely-new data arrived, causing spurious
1102                    // secondary version bumps → false T2 table misses for RIDER_ZLOB_TABLE etc.
1103                    if had_any_change {
1104                        let item_path = format!("{}/{}", subform_ptr, idx);
1105                        let updated_item = self
1106                            .eval_data
1107                            .get(&item_path)
1108                            .cloned()
1109                            .unwrap_or(Value::Null);
1110                        // Update parent T1 cache snapshot
1111                        if let Some(c) = self.eval_cache.subform_caches.get_mut(&idx) {
1112                            c.item_snapshot = updated_item.clone();
1113                        }
1114                        // Update subform's own per-item snapshot used as old_item_snapshot
1115                        // on the next evaluate_subform call.
1116                        subform.eval_cache.ensure_active_item_cache(idx);
1117                        if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
1118                            sub_cache.item_snapshot = updated_item;
1119                        }
1120                    }
1121                }
1122            }
1123        }
1124        Ok(any_table_invalidated)
1125    }
1126
1127    /// Helper to evaluate a dependent value - uses pre-compiled eval keys for fast lookup
1128    pub(crate) fn evaluate_dependent_value_static(
1129        engine: &RLogic,
1130        evaluations: &IndexMap<String, LogicId>,
1131        eval_data: &EvalData,
1132        value: &Value,
1133        changed_field_value: &Value,
1134        changed_field_ref_value: &Value,
1135    ) -> Result<Value, String> {
1136        match value {
1137            // If it's a String, check if it's an eval key reference
1138            Value::String(eval_key) => {
1139                if let Some(logic_id) = evaluations.get(eval_key) {
1140                    // It's a pre-compiled evaluation - run it with scoped context
1141                    // Create internal context with $value and $refValue
1142                    let mut internal_context = serde_json::Map::new();
1143                    internal_context.insert("$value".to_string(), changed_field_value.clone());
1144                    internal_context.insert("$refValue".to_string(), changed_field_ref_value.clone());
1145                    let context_value = Value::Object(internal_context);
1146
1147                    let result = engine.run_with_context(logic_id, eval_data.data(), &context_value)
1148                        .map_err(|e| format!("Failed to evaluate dependent logic '{}': {}", eval_key, e))?;
1149                    Ok(result)
1150                } else {
1151                    // It's a regular string value
1152                    Ok(value.clone())
1153                }
1154            }
1155            // For backwards compatibility: compile $evaluation on-the-fly
1156            // This shouldn't happen with properly parsed schemas
1157            Value::Object(map) if map.contains_key("$evaluation") => {
1158                Err("Dependent evaluation contains unparsed $evaluation - schema was not properly parsed".to_string())
1159            }
1160            // Primitive value - return as-is
1161            _ => Ok(value.clone()),
1162        }
1163    }
1164
1165    /// Check if a single field is readonly and populate vectors for both changes and all values
1166    pub(crate) fn check_readonly_for_dependents(
1167        &self,
1168        schema_element: &Value,
1169        path: &str,
1170        changes: &mut Vec<(String, Value)>,
1171        all_values: &mut Vec<(String, Value)>,
1172    ) {
1173        match schema_element {
1174            Value::Object(map) => {
1175                // Check if field is disabled (ReadOnly)
1176                let mut is_disabled = false;
1177                if let Some(Value::Object(condition)) = map.get("condition") {
1178                    if let Some(Value::Bool(d)) = condition.get("disabled") {
1179                        is_disabled = *d;
1180                    }
1181                }
1182
1183                // Check skipReadOnlyValue config
1184                let mut skip_readonly = false;
1185                if let Some(Value::Object(config)) = map.get("config") {
1186                    if let Some(Value::Object(all)) = config.get("all") {
1187                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
1188                            skip_readonly = *skip;
1189                        }
1190                    }
1191                }
1192
1193                if is_disabled && !skip_readonly {
1194                    if let Some(schema_value) = map.get("value") {
1195                        let data_path = path_utils::schema_path_to_data_pointer(path)
1196                            // Strip the schema /value/ wrapper that appears in subform array item
1197                            // paths, e.g. #/riders/value/0/sa → /riders/0/sa (correct data pointer).
1198                            .replace("/value/", "/");
1199
1200                        let current_data = self
1201                            .eval_data
1202                            .data()
1203                            .pointer(&data_path)
1204                            .unwrap_or(&Value::Null);
1205
1206                        // Emit to all_values regardless of change (frontend needs $readonly value);
1207                        // add to changes only when eval_data differs from the schema value.
1208                        all_values.push((path.to_string(), schema_value.clone()));
1209                        if current_data != schema_value {
1210                            changes.push((path.to_string(), schema_value.clone()));
1211                        }
1212                    }
1213                }
1214            }
1215            _ => {}
1216        }
1217    }
1218
1219    /// Recursively collect read-only fields that need updates (Legacy/Full-Scan)
1220    #[allow(dead_code)]
1221    pub(crate) fn collect_readonly_fixes(
1222        &self,
1223        schema_element: &Value,
1224        path: &str,
1225        changes: &mut Vec<(String, Value)>,
1226    ) {
1227        match schema_element {
1228            Value::Object(map) => {
1229                // Check if field is disabled (ReadOnly)
1230                let mut is_disabled = false;
1231                if let Some(Value::Object(condition)) = map.get("condition") {
1232                    if let Some(Value::Bool(d)) = condition.get("disabled") {
1233                        is_disabled = *d;
1234                    }
1235                }
1236
1237                // Check skipReadOnlyValue config
1238                let mut skip_readonly = false;
1239                if let Some(Value::Object(config)) = map.get("config") {
1240                    if let Some(Value::Object(all)) = config.get("all") {
1241                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
1242                            skip_readonly = *skip;
1243                        }
1244                    }
1245                }
1246
1247                if is_disabled && !skip_readonly {
1248                    // Check if it's a value field (has "value" property or implicit via path?)
1249                    // In JS: "const readOnlyValues = this.getSchemaValues();"
1250                    // We only care if data != schema value
1251                    if let Some(schema_value) = map.get("value") {
1252                        let data_path = path_utils::schema_path_to_data_pointer(path).into_owned();
1253
1254                        let current_data = self
1255                            .eval_data
1256                            .data()
1257                            .pointer(&data_path)
1258                            .unwrap_or(&Value::Null);
1259
1260                        if current_data != schema_value {
1261                            changes.push((path.to_string(), schema_value.clone()));
1262                        }
1263                    }
1264                }
1265
1266                // Recurse into properties
1267                if let Some(Value::Object(props)) = map.get("properties") {
1268                    for (key, val) in props {
1269                        let next_path = if path == "#" {
1270                            format!("#/properties/{}", key)
1271                        } else {
1272                            format!("{}/properties/{}", path, key)
1273                        };
1274                        self.collect_readonly_fixes(val, &next_path, changes);
1275                    }
1276                }
1277            }
1278            _ => {}
1279        }
1280    }
1281
1282    /// Check if a single field is hidden and needs clearing (Optimized non-recursive)
1283    pub(crate) fn check_hidden_field(
1284        &self,
1285        schema_element: &Value,
1286        path: &str,
1287        hidden_fields: &mut Vec<String>,
1288    ) {
1289        match schema_element {
1290            Value::Object(map) => {
1291                // Check if field is hidden
1292                let mut is_hidden = false;
1293                if let Some(Value::Object(condition)) = map.get("condition") {
1294                    if let Some(Value::Bool(h)) = condition.get("hidden") {
1295                        is_hidden = *h;
1296                    }
1297                }
1298
1299                // Check keepHiddenValue config
1300                let mut keep_hidden = false;
1301                if let Some(Value::Object(config)) = map.get("config") {
1302                    if let Some(Value::Object(all)) = config.get("all") {
1303                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
1304                            keep_hidden = *keep;
1305                        }
1306                    }
1307                }
1308
1309                if is_hidden && !keep_hidden {
1310                    let data_path = path_utils::schema_path_to_data_pointer(path).into_owned();
1311
1312                    let current_data = self
1313                        .eval_data
1314                        .data()
1315                        .pointer(&data_path)
1316                        .unwrap_or(&Value::Null);
1317
1318                    // If hidden and has non-empty value, add to list
1319                    if current_data != &Value::Null && current_data != "" {
1320                        hidden_fields.push(path.to_string());
1321                    }
1322                }
1323            }
1324            _ => {}
1325        }
1326    }
1327
1328    /// Check a field hidden by layout ancestry and needing data clearing.
1329    fn check_effectively_hidden_field(
1330        &self,
1331        schema_element: &Value,
1332        path: &str,
1333        hidden_fields: &mut Vec<String>,
1334    ) {
1335        let Value::Object(map) = schema_element else {
1336            return;
1337        };
1338
1339        let keep_hidden = map
1340            .get("config")
1341            .and_then(Value::as_object)
1342            .and_then(|config| config.get("all"))
1343            .and_then(Value::as_object)
1344            .and_then(|all| all.get("keepHiddenValue"))
1345            .and_then(Value::as_bool)
1346            .unwrap_or(false);
1347        if keep_hidden {
1348            return;
1349        }
1350
1351        let current_data = self
1352            .eval_data
1353            .data()
1354            .pointer(&path_utils::schema_path_to_data_pointer(path))
1355            .unwrap_or(&Value::Null);
1356        if current_data != &Value::Null && current_data != "" {
1357            hidden_fields.push(path.to_string());
1358        }
1359    }
1360
1361    /// Recursively collect hidden fields that have values (candidates for clearing) (Legacy/Full-Scan)
1362    #[allow(dead_code)]
1363    pub(crate) fn collect_hidden_fields(
1364        &self,
1365        schema_element: &Value,
1366        path: &str,
1367        hidden_fields: &mut Vec<String>,
1368    ) {
1369        match schema_element {
1370            Value::Object(map) => {
1371                // Check if field is hidden
1372                let mut is_hidden = false;
1373                if let Some(Value::Object(condition)) = map.get("condition") {
1374                    if let Some(Value::Bool(h)) = condition.get("hidden") {
1375                        is_hidden = *h;
1376                    }
1377                }
1378
1379                // Check keepHiddenValue config
1380                let mut keep_hidden = false;
1381                if let Some(Value::Object(config)) = map.get("config") {
1382                    if let Some(Value::Object(all)) = config.get("all") {
1383                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
1384                            keep_hidden = *keep;
1385                        }
1386                    }
1387                }
1388
1389                if is_hidden && !keep_hidden {
1390                    let data_path = path_utils::schema_path_to_data_pointer(path).into_owned();
1391
1392                    let current_data = self
1393                        .eval_data
1394                        .data()
1395                        .pointer(&data_path)
1396                        .unwrap_or(&Value::Null);
1397
1398                    // If hidden and has non-empty value, add to list
1399                    if current_data != &Value::Null && current_data != "" {
1400                        hidden_fields.push(path.to_string());
1401                    }
1402                }
1403
1404                // Recurse into children
1405                for (key, val) in map {
1406                    if key == "properties" {
1407                        if let Value::Object(props) = val {
1408                            for (p_key, p_val) in props {
1409                                let next_path = if path == "#" {
1410                                    format!("#/properties/{}", p_key)
1411                                } else {
1412                                    format!("{}/properties/{}", path, p_key)
1413                                };
1414                                self.collect_hidden_fields(p_val, &next_path, hidden_fields);
1415                            }
1416                        }
1417                    } else if let Value::Object(_) = val {
1418                        // Skip known metadata keys and explicitly handled keys
1419                        if key == "condition"
1420                            || key == "config"
1421                            || key == "rules"
1422                            || key == "dependents"
1423                            || key == "hideLayout"
1424                            || key == "$layout"
1425                            || key == "$params"
1426                            || key == "definitions"
1427                            || key == "$defs"
1428                            || key.starts_with('$')
1429                        {
1430                            continue;
1431                        }
1432
1433                        let next_path = if path == "#" {
1434                            format!("#/{}", key)
1435                        } else {
1436                            format!("{}/{}", path, key)
1437                        };
1438                        self.collect_hidden_fields(val, &next_path, hidden_fields);
1439                    }
1440                }
1441            }
1442            _ => {}
1443        }
1444    }
1445
1446    /// Perform recursive hiding effect using reffed_by graph.
1447    /// Collects every data path that gets nulled into `invalidated_paths`.
1448    pub(crate) fn recursive_hide_effect(
1449        engine: &RLogic,
1450        evaluations: &IndexMap<String, LogicId>,
1451        reffed_by: &IndexMap<String, Vec<String>>,
1452        eval_data: &mut EvalData,
1453        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1454        mut hidden_fields: Vec<String>,
1455        queue: &mut Vec<(String, bool, Option<Vec<usize>>)>,
1456        result: &mut Vec<Value>,
1457    ) {
1458        while let Some(hf) = hidden_fields.pop() {
1459            let data_path = path_utils::schema_path_to_data_pointer(&hf).into_owned();
1460
1461            // clear data
1462            eval_data.set(&data_path, Value::Null);
1463            eval_cache.bump_data_version(&data_path);
1464
1465            // Create dependent object for result
1466            let mut change_obj = serde_json::Map::new();
1467            change_obj.insert(
1468                "$ref".to_string(),
1469                Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1470            );
1471            change_obj.insert("$hidden".to_string(), Value::Bool(true));
1472            change_obj.insert("clear".to_string(), Value::Bool(true));
1473            result.push(Value::Object(change_obj));
1474
1475            // Add to queue for standard dependent processing
1476            queue.push((hf.clone(), true, None));
1477
1478            // Check reffed_by to find other fields that might become hidden
1479            if let Some(referencing_fields) = reffed_by.get(&data_path) {
1480                for rb in referencing_fields {
1481                    // Evaluate condition.hidden for rb
1482                    // We need a way to run specific evaluation?
1483                    // We can check if rb has a hidden evaluation in self.evaluations
1484                    let hidden_eval_key = format!("{}/condition/hidden", rb);
1485
1486                    if let Some(logic_id) = evaluations.get(&hidden_eval_key) {
1487                        // Run evaluation
1488                        // Context: $value = current field (rb) value? No, $value usually refers to changed field in deps.
1489                        // But here we are just re-evaluating the rule.
1490                        // In JS logic: "const result = hiddenFn(runnerCtx);"
1491                        // runnerCtx has the updated data (we just set hf to null).
1492
1493                        let rb_data_path = path_utils::schema_path_to_data_pointer(rb).into_owned();
1494                        let rb_value = eval_data
1495                            .data()
1496                            .pointer(&rb_data_path)
1497                            .cloned()
1498                            .unwrap_or(Value::Null);
1499
1500                        // We can use engine.run w/ eval_data
1501                        if let Ok(Value::Bool(is_hidden)) = engine.run(logic_id, eval_data.data()) {
1502                            if is_hidden {
1503                                // Check if rb is not already in hidden_fields and has value
1504                                // rb is &String, hidden_fields is Vec<String>
1505                                if !hidden_fields.contains(rb) {
1506                                    let has_value = rb_value != Value::Null && rb_value != "";
1507                                    if has_value {
1508                                        hidden_fields.push(rb.clone());
1509                                    }
1510                                }
1511                            }
1512                        }
1513                    }
1514                }
1515            }
1516        }
1517    }
1518
1519    /// Process the dependents queue.
1520    /// Collects every data path written into `eval_data` into `invalidated_paths`.
1521    pub(crate) fn process_dependents_queue(
1522        engine: &RLogic,
1523        evaluations: &IndexMap<String, LogicId>,
1524        eval_data: &mut EvalData,
1525        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1526        dependents_evaluations: &IndexMap<String, Vec<DependentItem>>,
1527        dep_formula_triggers: &IndexMap<String, Vec<(String, usize)>>,
1528        evaluated_schema: &Value,
1529        queue: &mut Vec<(String, bool, Option<Vec<usize>>)>,
1530        processed: &mut std::collections::HashMap<String, Option<std::collections::HashSet<usize>>>,
1531        result: &mut Vec<Value>,
1532        token: Option<&CancellationToken>,
1533        canceled_paths: Option<&mut Vec<String>>,
1534    ) -> Result<(), String> {
1535        while let Some((current_path, is_transitive, target_indices)) = queue.pop() {
1536            if let Some(t) = token {
1537                if t.is_cancelled() {
1538                    if let Some(cp) = canceled_paths {
1539                        cp.push(current_path.clone());
1540                        for (path, _, _) in queue.iter() {
1541                            cp.push(path.clone());
1542                        }
1543                    }
1544                    return Err("Cancelled".to_string());
1545                }
1546            }
1547
1548            let (should_run, indices_to_run) = match processed.get(&current_path) {
1549                Some(None) => {
1550                    // Already fully processed, skip
1551                    continue;
1552                }
1553                Some(Some(already_processed_indices)) => {
1554                    if let Some(targets) = &target_indices {
1555                        let new_targets: std::collections::HashSet<usize> = targets
1556                            .iter()
1557                            .copied()
1558                            .filter(|i| !already_processed_indices.contains(i))
1559                            .collect();
1560                        if new_targets.is_empty() {
1561                            continue;
1562                        }
1563                        (true, Some(new_targets))
1564                    } else {
1565                        (true, None)
1566                    }
1567                }
1568                None => (
1569                    true,
1570                    target_indices.clone().map(|t| t.into_iter().collect()),
1571                ),
1572            };
1573
1574            if !should_run {
1575                continue;
1576            }
1577
1578            let new_processed_state = if let Some(targets_to_run) = &indices_to_run {
1579                match processed.get(&current_path) {
1580                    Some(Some(existing_targets)) => {
1581                        let mut copy = existing_targets.clone();
1582                        for t in targets_to_run {
1583                            copy.insert(*t);
1584                        }
1585                        Some(copy)
1586                    }
1587                    _ => Some(targets_to_run.clone()),
1588                }
1589            } else {
1590                None
1591            };
1592            processed.insert(current_path.clone(), new_processed_state);
1593
1594            // Get the value of the changed field for $value context
1595            let current_data_path =
1596                path_utils::schema_path_to_data_pointer(&current_path).into_owned();
1597            let mut current_value = eval_data
1598                .data()
1599                .pointer(&current_data_path)
1600                .cloned()
1601                .unwrap_or(Value::Null);
1602
1603            // Re-enqueue source fields whose dependent formulas reference this changed field.
1604            // These are fields that have a dependent formula that checks `current_path` as a
1605            // contextual condition (e.g., ins_occ's formula for ph_occupation checks phins_relation).
1606            // When `current_path` changes, we need to re-evaluate those source fields' dependents.
1607            if let Some(formula_sources) = dep_formula_triggers.get(&current_data_path) {
1608                let mut targets_by_source: std::collections::HashMap<String, Vec<usize>> =
1609                    std::collections::HashMap::new();
1610                for (source_schema_path, dep_idx) in formula_sources {
1611                    let source_ptr = path_utils::dot_notation_to_schema_pointer(source_schema_path);
1612                    targets_by_source
1613                        .entry(source_ptr)
1614                        .or_default()
1615                        .push(*dep_idx);
1616                }
1617                for (source_ptr, targets) in targets_by_source {
1618                    // Check if it's already entirely processed
1619                    if let Some(None) = processed.get(&source_ptr) {
1620                        continue;
1621                    }
1622                    queue.push((source_ptr, true, Some(targets)));
1623                }
1624            }
1625
1626            // Find dependents for this path
1627            if let Some(dependent_items) = dependents_evaluations.get(&current_path) {
1628                for (dep_idx, dep_item) in dependent_items.iter().enumerate() {
1629                    if let Some(targets) = &indices_to_run {
1630                        if !targets.contains(&dep_idx) {
1631                            continue;
1632                        }
1633                    }
1634                    let ref_path = &dep_item.ref_path;
1635                    let pointer_path = path_utils::normalize_to_json_pointer(ref_path);
1636                    // Data paths don't include /properties/, strip it for data access
1637                    let data_path =
1638                        crate::jsoneval::path_utils::schema_path_to_data_pointer(&pointer_path)
1639                            .into_owned();
1640
1641                    let current_ref_value = eval_data
1642                        .data()
1643                        .pointer(&data_path)
1644                        .cloned()
1645                        .unwrap_or(Value::Null);
1646
1647                    // Get field and parent field from schema
1648                    let field = evaluated_schema.pointer(&pointer_path).cloned();
1649
1650                    // Get parent field - skip /properties/ to get actual parent object
1651                    let parent_path = if let Some(last_slash) = pointer_path.rfind("/properties") {
1652                        &pointer_path[..last_slash]
1653                    } else {
1654                        "/"
1655                    };
1656                    let mut parent_field = if parent_path.is_empty() || parent_path == "/" {
1657                        evaluated_schema.clone()
1658                    } else {
1659                        evaluated_schema
1660                            .pointer(parent_path)
1661                            .cloned()
1662                            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
1663                    };
1664
1665                    // omit properties to minimize size of parent field
1666                    if let Value::Object(ref mut map) = parent_field {
1667                        map.remove("properties");
1668                        map.remove("$layout");
1669                    }
1670
1671                    let mut change_obj = serde_json::Map::new();
1672                    change_obj.insert(
1673                        "$ref".to_string(),
1674                        Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1675                    );
1676                    if let Some(f) = field {
1677                        change_obj.insert("$field".to_string(), f);
1678                    }
1679                    change_obj.insert("$parentField".to_string(), parent_field);
1680                    change_obj.insert("transitive".to_string(), Value::Bool(is_transitive));
1681
1682                    // Skip writing back to a field that has already been processed.
1683                    // This prevents formula-triggered re-enqueues from creating circular writes:
1684                    // e.g., ins_gender → triggers phins_relation (via dep_formula_triggers) →
1685                    // phins_relation has a dep that writes back to ins_gender → we must not let that happen.
1686                    if processed.contains_key(ref_path) {
1687                        continue;
1688                    }
1689
1690                    let mut add_transitive = false;
1691                    let mut add_deps = false;
1692                    // Process clear
1693                    if let Some(clear_val) = &dep_item.clear {
1694                        let should_clear = Self::evaluate_dependent_value_static(
1695                            engine,
1696                            evaluations,
1697                            eval_data,
1698                            clear_val,
1699                            &current_value,
1700                            &current_ref_value,
1701                        )?;
1702                        let clear_bool = match should_clear {
1703                            Value::Bool(b) => b,
1704                            _ => false,
1705                        };
1706
1707                        if clear_bool {
1708                            if data_path == current_data_path {
1709                                current_value = Value::Null;
1710                            }
1711                            eval_data.set(&data_path, Value::Null);
1712                            eval_cache.bump_data_version(&data_path);
1713                            change_obj.insert("clear".to_string(), Value::Bool(true));
1714                            add_transitive = true;
1715                            add_deps = true;
1716                        }
1717                    }
1718
1719                    // Process value
1720                    if let Some(value_val) = &dep_item.value {
1721                        let computed_value = Self::evaluate_dependent_value_static(
1722                            engine,
1723                            evaluations,
1724                            eval_data,
1725                            value_val,
1726                            &current_value,
1727                            &current_ref_value,
1728                        )?;
1729                        let cleaned_val = clean_float_noise_scalar(computed_value);
1730
1731                        let is_clear =
1732                            cleaned_val == Value::Null || cleaned_val.as_str() == Some("");
1733
1734                        if cleaned_val != current_ref_value && !is_clear {
1735                            if data_path == current_data_path {
1736                                current_value = cleaned_val.clone();
1737                            }
1738                            eval_data.set(&data_path, cleaned_val.clone());
1739                            eval_cache.bump_data_version(&data_path);
1740                            change_obj.insert("value".to_string(), cleaned_val);
1741                            add_transitive = true;
1742                            add_deps = true;
1743                        }
1744                    }
1745
1746                    // add only when has clear / value
1747                    if add_deps {
1748                        result.push(Value::Object(change_obj));
1749                    }
1750
1751                    // Add this dependent to queue for transitive processing
1752                    if add_transitive {
1753                        queue.push((ref_path.clone(), true, None));
1754                    }
1755                }
1756            }
1757        }
1758        Ok(())
1759    }
1760}
1761
1762/// Extract the field key from a subform path.
1763///
1764/// Examples:
1765/// - `#/riders`                               → `riders`
1766/// - `#/properties/form/properties/riders`    → `riders`
1767/// - `#/items`                                → `items`
1768fn subform_field_key(subform_path: &str) -> String {
1769    // Strip leading `#/`
1770    let stripped = subform_path.trim_start_matches('#').trim_start_matches('/');
1771
1772    // The last non-"properties" segment is the field key
1773    stripped
1774        .split('/')
1775        .filter(|seg| !seg.is_empty() && *seg != "properties")
1776        .last()
1777        .unwrap_or(stripped)
1778        .to_string()
1779}