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