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