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