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 mut parent_affected: std::collections::HashSet<String> = parent_changed_paths
750                .iter()
751                .filter(|path| {
752                    !path.starts_with(&subform_dot_path)
753                        && !path.starts_with(&field_prefix)
754                        && !path.starts_with(&format!("{}.", field_key))
755                })
756                .map(|path| {
757                    path_utils::dot_notation_to_schema_pointer(path)
758                        .trim_start_matches('#')
759                        .trim_start_matches('/')
760                        .to_string()
761                })
762                .collect();
763
764
765            // Transitively expand parent_affected through self.dependencies
766            // (e.g. prem_freq -> WOP_ZLOB_PREMI_TABLE -> wop_rider_premi)
767            let mut queue: std::collections::VecDeque<String> =
768                parent_affected.iter().cloned().collect();
769            while let Some(current) = queue.pop_front() {
770                for (target, deps) in self.dependencies.iter() {
771                    let clean_target = target.trim_start_matches('#').trim_start_matches('/');
772                    if !parent_affected.contains(clean_target) {
773                        let is_affected = deps.iter().any(|dep| {
774                            let clean_dep = dep.trim_start_matches('#').trim_start_matches('/');
775                            clean_dep == current
776                                || (current.starts_with(clean_dep)
777                                    && current.as_bytes().get(clean_dep.len()) == Some(&b'/'))
778                        });
779                        if is_affected {
780                            let target_str = clean_target.to_string();
781                            parent_affected.insert(target_str.clone());
782                            queue.push_back(target_str);
783                        }
784                    }
785                }
786            }
787
788            let dependent_value_paths: Vec<String> = self
789                .subforms
790                .get(&subform_path)
791                .map(|subform| {
792                    subform
793                        .dependencies
794                        .iter()
795                        .filter(|(key, deps)| {
796                            !subform.table_metadata.contains_key(*key)
797                                && !key.starts_with("#/$params/")
798                                && key.ends_with("/value")
799                                && deps.iter().any(|dep| {
800                                    let clean_dep = dep.trim_start_matches('#').trim_start_matches('/');
801                                    parent_affected.contains(clean_dep)
802                                        || parent_affected.iter().any(|p| {
803                                            p.starts_with(clean_dep)
804                                                && p.as_bytes().get(clean_dep.len()) == Some(&b'/')
805                                        })
806                                })
807                        })
808                        .map(|(key, _)| key.clone())
809                        .collect()
810                })
811                .unwrap_or_default();
812
813            // Detect table-backed outputs downstream of parent-derived rider values once per subform.
814            let refresh_table_outputs = self
815                .subforms
816                .get(&subform_path)
817                .map(|subform| {
818                    dependent_value_paths.iter().any(|source| {
819                        let source_clean = source.trim_end_matches("/value").trim_start_matches('#');
820                        let affected_tables: Vec<&String> = subform
821                            .table_metadata
822                            .keys()
823                            .filter(|table| table.starts_with("#/$params"))
824                            .filter(|table| {
825                                subform.dependencies.get(*table).is_some_and(|deps| {
826                                    deps.iter().any(|dep| dep.trim_start_matches('#') == source_clean)
827                                })
828                            })
829                            .collect();
830
831                        !affected_tables.is_empty()
832                            && subform.evaluations.iter().any(|(target, _)| {
833                                target.ends_with("/value")
834                                    && subform.dependencies.get(target).is_some_and(|deps| {
835                                        deps.iter().any(|dep| {
836                                            affected_tables.iter().any(|table| {
837                                                dep.trim_start_matches('#')
838                                                    == table.trim_start_matches('#')
839                                            })
840                                        })
841                                    })
842                            })
843                    })
844                })
845                .unwrap_or(false);
846
847            // Sync parent params and static arrays to subform once before the item loop
848            if let Some(subform) = self.subforms.get_mut(&subform_path) {
849                if let Some(params) = self.evaluated_schema.pointer("/$params") {
850                    if let Some(sub_params) = subform.evaluated_schema.pointer_mut("/$params") {
851                        *sub_params = params.clone();
852                    }
853                }
854                subform.static_arrays = std::sync::Arc::clone(&self.static_arrays);
855                subform.engine.set_static_arrays(std::sync::Arc::clone(&subform.static_arrays));
856            }
857
858            for idx in 0..item_count {
859                // Map absolute changed paths → subform-internal paths for this item index
860                let prefix_dot = format!("{}.{}.", subform_dot_path, idx);
861                let prefix_bracket = format!("{}[{}].", subform_dot_path, idx);
862                let prefix_field_bracket = format!("{}[{}].", field_key, idx);
863
864                let is_collection_refresh = changed_paths
865                    .iter()
866                    .any(|path| path == &format!("{subform_dot_path}.{idx}"));
867                let item_changed_paths: Vec<String> = changed_paths
868                    .iter()
869                    .filter_map(|p| {
870                        if p == &format!("{subform_dot_path}.{idx}") {
871                            Some(field_key.clone())
872                        } else if p.starts_with(&prefix_bracket) {
873                            Some(p.replacen(&prefix_bracket, &field_prefix, 1))
874                        } else if p.starts_with(&prefix_dot) {
875                            Some(p.replacen(&prefix_dot, &field_prefix, 1))
876                        } else if p.starts_with(&prefix_field_bracket) {
877                            Some(p.replacen(&prefix_field_bracket, &field_prefix, 1))
878                        } else {
879                            None
880                        }
881                    })
882                    .collect();
883
884                // Build minimal merged data: clone only item at idx, share $params shallowly.
885                // This avoids cloning the full 5MB parent payload for every item.
886                let item_val =
887                    get_value_by_pointer_without_properties(self.eval_data.data(), &subform_ptr)
888                        .and_then(|v| v.as_array())
889                        .and_then(|a| a.get(idx))
890                        .cloned()
891                        .unwrap_or(Value::Null);
892
893                if item_changed_paths.is_empty() && !dependent_value_paths.is_empty() {
894                    // Parent-only changes need an item-local overlay. Computed item values must
895                    // be visible to their own `dependents` (for example wop_flag=false clears
896                    // both WOP rider fields), but publishing into parent eval_data or its cache
897                    // makes later rider/table passes observe a synthetic input change.
898                    let parent_cache = std::mem::take(&mut self.eval_cache);
899                    let mut overlay_cache = parent_cache.clone();
900                    overlay_cache.ensure_active_item_cache(idx);
901                    if let Some(item_cache) = overlay_cache.subform_caches.get_mut(&idx) {
902                        // T1 checks use item versions, not parent versions. Merge parent changes
903                        // into disposable overlay state so relation-driven formulas cannot reuse
904                        // an earlier rider result (e.g. cached wop_flag=true).
905                        item_cache
906                            .data_versions
907                            .merge_from(&parent_data_versions_snapshot);
908                        item_cache
909                            .data_versions
910                            .merge_from_params(&parent_params_versions_snapshot);
911                    }
912                    overlay_cache.set_active_item(idx);
913                    let canonical_root =
914                        path_utils::schema_path_to_data_pointer(&subform_path).into_owned();
915                    let scope = crate::jsoneval::subform_scope::SubformScope::new(
916                        &subform_path,
917                        &canonical_root,
918                        Some(idx),
919                    );
920                    let mut scoped_view = scope.evaluation_view(self.eval_data.data());
921                    if let Some(view) = scoped_view.as_object_mut() {
922                        view.insert(
923                            "$context".to_string(),
924                            self.eval_data
925                                .data()
926                                .get("$context")
927                                .cloned()
928                                .unwrap_or(Value::Null),
929                        );
930                    }
931                    let subform = self
932                        .subforms
933                        .get_mut(&subform_path)
934                        .expect("subform exists");
935                    subform.eval_data = EvalData::new(scoped_view);
936                    std::mem::swap(&mut subform.eval_cache, &mut overlay_cache);
937                    subform.evaluate_internal(Some(&dependent_value_paths), token)?;
938
939                    let mut overlay_result = Vec::new();
940                    let mut overlay_queue = Vec::new();
941                    let mut overlay_processed = std::collections::HashMap::new();
942                    for formula_path in &dependent_value_paths {
943                        let schema_path = path_utils::normalize_to_json_pointer(formula_path);
944                        let data_path = path_utils::schema_path_to_data_pointer(formula_path)
945                            .replace("/value", "");
946                        let Some(value) = subform.evaluated_schema.pointer(&schema_path).cloned()
947                        else {
948                            continue;
949                        };
950
951                        // Make this computed value available only to direct dependent formulas.
952                        // The overlay is discarded below, but its version must still advance: the
953                        // re-evaluation pass uses it to invalidate formulas and tables derived
954                        // from this value (for example wop_rider_premi after its benefit changes).
955                        let source_path = formula_path.trim_end_matches("/value").to_string();
956                        if subform.eval_data.get(&data_path) != Some(&value) {
957                            subform.eval_data.set(&data_path, value.clone());
958                            subform
959                                .eval_data
960                                .set(&scope.canonical_path(&data_path), value.clone());
961                            subform.eval_cache.bump_data_version(&data_path);
962                        }
963                        overlay_queue.push((source_path, true, None));
964
965                        let mut change = serde_json::Map::new();
966                        let field = data_path
967                            .trim_start_matches('/')
968                            .trim_end_matches("/value")
969                            .replace('/', ".");
970                        let field = field.strip_prefix(&field_prefix).unwrap_or(&field);
971                        change.insert(
972                            "$ref".to_string(),
973                            Value::String(format!("{}.{}.{}", subform_dot_path, idx, field)),
974                        );
975                        if value == Value::Null || value.as_str() == Some("") {
976                            change.insert("clear".to_string(), Value::Bool(true));
977                        } else {
978                            change.insert("value".to_string(), value);
979                        }
980                        result.push(Value::Object(change));
981                    }
982
983
984                    Self::process_dependents_queue(
985                        &subform.engine,
986                        &subform.evaluations,
987                        &mut subform.eval_data,
988                        &mut subform.eval_cache,
989                        &subform.dependents_evaluations,
990                        &subform.dep_formula_triggers,
991                        &subform.evaluated_schema,
992                        &mut overlay_queue,
993                        &mut overlay_processed,
994                        &mut overlay_result,
995                        token,
996                        None,
997                    )?;
998
999                    // Dependents write local aliases. Synchronize active item before tables
1000                    // resolve absolute parent paths inside this disposable overlay.
1001                    let local_item_path = format!("/{field_key}");
1002                    if let Some(local_item) = subform.eval_data.get(&local_item_path).cloned() {
1003                        subform
1004                            .eval_data
1005                            .set(&scope.canonical_path(&local_item_path), local_item);
1006                    }
1007
1008                    if refresh_table_outputs {
1009                        subform.run_re_evaluate_pass(
1010                            token,
1011                            &mut overlay_queue,
1012                            &mut overlay_processed,
1013                            &mut overlay_result,
1014                            None,
1015                        )?;
1016                    }
1017
1018                    for change in overlay_result {
1019                        let Some(object) = change.as_object() else {
1020                            continue;
1021                        };
1022                        let Some(Value::String(ref_path)) = object.get("$ref") else {
1023                            continue;
1024                        };
1025                        let local_ref = ref_path.strip_prefix(&field_prefix).unwrap_or(ref_path);
1026                        let mut mapped = object.clone();
1027                        mapped.insert(
1028                            "$ref".to_string(),
1029                            Value::String(format!("{}.{}.{}", subform_dot_path, idx, local_ref)),
1030                        );
1031                        result.push(Value::Object(mapped));
1032                    }
1033
1034                    // Restore subform's durable cache and discard overlay mutations. Keep the
1035                    // parent's cache byte-for-byte unchanged for remaining items/tables.
1036                    std::mem::swap(&mut subform.eval_cache, &mut overlay_cache);
1037                    self.eval_cache = parent_cache;
1038                    continue;
1039                }
1040
1041                let canonical_root =
1042                    path_utils::schema_path_to_data_pointer(&subform_path).into_owned();
1043                let scope = crate::jsoneval::subform_scope::SubformScope::new(
1044                    &subform_path,
1045                    &canonical_root,
1046                    Some(idx),
1047                );
1048                let mut scoped_view = scope.evaluation_view(self.eval_data.data());
1049                if let Some(view) = scoped_view.as_object_mut() {
1050                    view.insert(
1051                        "$context".to_string(),
1052                        self.eval_data
1053                            .data()
1054                            .get("$context")
1055                            .cloned()
1056                            .unwrap_or(Value::Null),
1057                    );
1058                }
1059                let Some(subform) = self.subforms.get_mut(&subform_path) else {
1060                    continue;
1061                };
1062
1063                // Parent-only re-evaluation already refreshes global `$params` tables and main-form
1064                // readonly values. Re-running every subform can evaluate a table with the transient
1065                // parent state (for example a cleared `prem_pay_period`) and overwrite fresh global
1066                // rows with an empty table. Only item-specific changed paths re-evaluate subforms.
1067                let sub_re_evaluate = !item_changed_paths.is_empty();
1068                if !sub_re_evaluate && item_changed_paths.is_empty() {
1069                    continue;
1070                }
1071
1072                // Prepare cache state for this item
1073                self.eval_cache.ensure_active_item_cache(idx);
1074                let old_item_val = {
1075                    let snapshot = self
1076                        .eval_cache
1077                        .subform_caches
1078                        .get(&idx)
1079                        .map(|c| c.item_snapshot.clone())
1080                        .unwrap_or(Value::Null);
1081
1082                    if snapshot == Value::Null {
1083                        if let Some(main_snap) = &self.eval_cache.main_form_snapshot {
1084                            get_value_by_pointer_without_properties(main_snap, &subform_ptr)
1085                                .and_then(|v| v.as_array())
1086                                .and_then(|a| a.get(idx))
1087                                .cloned()
1088                                .unwrap_or(Value::Null)
1089                        } else {
1090                            Value::Null
1091                        }
1092                    } else {
1093                        snapshot
1094                    }
1095                };
1096
1097                subform.eval_data = EvalData::new(scoped_view);
1098                let new_item_val = item_val.clone();
1099
1100                // Cache-swap: lend parent cache to subform
1101                let mut parent_cache = std::mem::take(&mut self.eval_cache);
1102                parent_cache.ensure_active_item_cache(idx);
1103
1104                // Snapshot item data_versions BEFORE the diff so we can detect which paths
1105                // are newly bumped by this specific diff pass (vs historical bumps from prior calls).
1106                let pre_diff_item_versions = parent_cache
1107                    .subform_caches
1108                    .get(&idx)
1109                    .map(|c| c.data_versions.clone());
1110
1111                if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
1112                    // Merge all data versions from the parent snapshot. We must include non-$params
1113                    // paths so that parent field updates (like wop_basic_benefit changing) correctly
1114                    // invalidate subform per-item cache entries that depend on them.
1115                    c.data_versions.merge_from(&parent_data_versions_snapshot);
1116                    // Always reflect the latest $params (schema-level, index-independent).
1117                    c.data_versions
1118                        .merge_from_params(&parent_params_versions_snapshot);
1119                    if !is_collection_refresh {
1120                        crate::jsoneval::eval_cache::diff_and_update_versions(
1121                            &mut c.data_versions,
1122                            &format!("/{}", field_key),
1123                            &old_item_val,
1124                            &new_item_val,
1125                            "run_subform_pass_diff_and_update_versions",
1126                        );
1127                    }
1128                    // Parent evaluation prepared this item before the refresh. Its computed
1129                    // leaves establish the cache baseline, not a rider-input mutation.
1130                    c.item_snapshot = new_item_val;
1131                }
1132
1133
1134                // Invalidate stale T2 $params table entries whose deps overlap any path newly
1135                //
1136                // These tables MUST be re-evaluated by the subform engine (not here) because
1137                // their formulas read subform-local paths like #/riders/properties/sa which
1138                // only resolve correctly when the active item is injected under the riders key.
1139                {
1140                    let field_prefix_slash = format!("/{}/", field_key);
1141                    let newly_bumped_schema_paths: Vec<String> = if let (Some(ref pre), Some(c)) = (
1142                        &pre_diff_item_versions,
1143                        parent_cache.subform_caches.get(&idx),
1144                    ) {
1145                        c.data_versions
1146                            .versions()
1147                            .filter(|(k, &v)| k.starts_with(&field_prefix_slash) && v > pre.get(k))
1148                            .map(|(k, _)| {
1149                                // Convert data-version path (e.g. /riders/sa) to schema dep
1150                                // format (e.g. /riders/properties/sa) for dep matching against
1151                                // self.dependencies, which stores paths WITHOUT the '#' prefix.
1152                                let sub = k.trim_start_matches(&field_prefix_slash);
1153                                format!(
1154                                    "/{}/properties/{}",
1155                                    field_key,
1156                                    sub.replace('/', "/properties/")
1157                                )
1158                            })
1159                            .collect()
1160                    } else {
1161                        Vec::new()
1162                    };
1163
1164                    if !newly_bumped_schema_paths.is_empty() {
1165                        let params_table_keys: Vec<String> = self
1166                            .table_metadata
1167                            .keys()
1168                            .filter(|k| k.starts_with("#/$params"))
1169                            .filter(|k| {
1170                                self.dependencies
1171                                    .get(*k)
1172                                    .map(|deps| {
1173                                        deps.iter().any(|dep| {
1174                                            let clean_dep = dep.trim_start_matches('#');
1175                                            newly_bumped_schema_paths
1176                                                .iter()
1177                                                .any(|b| {
1178                                                    let clean_b = b.trim_start_matches('#');
1179                                                    clean_dep == clean_b
1180                                                        || clean_dep.starts_with(clean_b)
1181                                                        || b == dep
1182                                                        || dep.starts_with(b.as_str())
1183                                                })
1184                                        })
1185                                    })
1186                                    .unwrap_or(false)
1187                            })
1188                            .cloned()
1189                            .collect();
1190
1191                        if !params_table_keys.is_empty() {
1192                            parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
1193                            any_table_invalidated = true;
1194                        }
1195                    }
1196                }
1197
1198                parent_cache.set_active_item(idx);
1199                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
1200
1201                let subform_result = time_block!("    [subform_pass] rider evaluate_dependents", {
1202                    subform.evaluate_dependents(
1203                        &item_changed_paths,
1204                        None,
1205                        None,
1206                        sub_re_evaluate,
1207                        token,
1208                        None,
1209                        false,
1210                    )
1211                });
1212
1213                // Restore parent cache
1214                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
1215                parent_cache.clear_active_item();
1216
1217                // Propagate the updated item_snapshot from the parent's T1 cache into the
1218                // subform's own eval_cache. Without this, subsequent evaluate_subform() calls
1219                // for this idx read the OLD snapshot (pre-run_subform_pass) and see a diff
1220                // against the new data → item_paths_bumped = true → spurious table invalidation.
1221                if let Some(parent_item_cache) = self.eval_cache.subform_caches.get(&idx) {
1222                    let snapshot = parent_item_cache.item_snapshot.clone();
1223                    subform.eval_cache.ensure_active_item_cache(idx);
1224                    if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
1225                        sub_cache.item_snapshot = snapshot;
1226                    }
1227                }
1228
1229                self.eval_cache = parent_cache;
1230
1231                if let Ok(Value::Array(changes)) = subform_result {
1232                    let mut had_any_change = false;
1233                    for change in changes {
1234                        if let Some(obj) = change.as_object() {
1235                            if let Some(Value::String(ref_path)) = obj.get("$ref") {
1236                                // Remap the $ref path to include the parent path + item index
1237                                let new_ref = if ref_path.starts_with(&field_prefix) {
1238                                    format!(
1239                                        "{}.{}.{}",
1240                                        subform_dot_path,
1241                                        idx,
1242                                        &ref_path[field_prefix.len()..]
1243                                    )
1244                                } else {
1245                                    format!("{}.{}.{}", subform_dot_path, idx, ref_path)
1246                                };
1247
1248                                // Write the computed value back to parent eval_data so subsequent
1249                                // evaluate_subform calls see an up-to-date old_item_snapshot.
1250                                // Without this, the diff in with_item_cache_swap sees stale parent
1251                                // data vs the new call's apply_changes values → spurious item bumps
1252                                // → invalidate_params_tables_for_item fires → eval_generation bumps.
1253                                if let Some(val) = obj.get("value") {
1254                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
1255                                    self.eval_data.set(&data_ptr, val.clone());
1256                                    had_any_change = true;
1257                                } else if obj.get("clear").and_then(Value::as_bool) == Some(true) {
1258                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
1259                                    self.eval_data.set(&data_ptr, Value::Null);
1260                                    had_any_change = true;
1261                                }
1262
1263                                let mut new_obj = obj.clone();
1264                                new_obj.insert("$ref".to_string(), Value::String(new_ref));
1265                                result.push(Value::Object(new_obj));
1266                            } else {
1267                                // No $ref rewrite needed — push as-is without cloning the map
1268                                result.push(change);
1269                            }
1270                        }
1271                    }
1272
1273                    // Refresh item snapshots after computed writes.
1274                    if had_any_change {
1275                        let item_path = format!("{}/{}", subform_ptr, idx);
1276                        let updated_item = self
1277                            .eval_data
1278                            .get(&item_path)
1279                            .cloned()
1280                            .unwrap_or(Value::Null);
1281                        // Update parent snapshot.
1282                        if let Some(c) = self.eval_cache.subform_caches.get_mut(&idx) {
1283                            c.item_snapshot = updated_item.clone();
1284                        }
1285                        // Update subform snapshot.
1286                        subform.eval_cache.ensure_active_item_cache(idx);
1287                        if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
1288                            sub_cache.item_snapshot = updated_item;
1289                        }
1290                    }
1291                }
1292            }
1293        }
1294        Ok(any_table_invalidated)
1295    }
1296
1297    /// Helper to evaluate a dependent value - uses pre-compiled eval keys for fast lookup
1298    pub(crate) fn evaluate_dependent_value_static(
1299        engine: &RLogic,
1300        evaluations: &IndexMap<String, LogicId>,
1301        eval_data: &EvalData,
1302        value: &Value,
1303        changed_field_value: &Value,
1304        changed_field_ref_value: &Value,
1305    ) -> Result<Value, String> {
1306        match value {
1307            // If it's a String, check if it's an eval key reference
1308            Value::String(eval_key) => {
1309                if let Some(logic_id) = evaluations.get(eval_key) {
1310                    // It's a pre-compiled evaluation - run it with scoped context
1311                    // Create internal context with $value and $refValue
1312                    let mut internal_context = serde_json::Map::new();
1313                    internal_context.insert("$value".to_string(), changed_field_value.clone());
1314                    internal_context.insert("$refValue".to_string(), changed_field_ref_value.clone());
1315                    let context_value = Value::Object(internal_context);
1316
1317                    let result = engine.run_with_context(logic_id, eval_data.data(), &context_value)
1318                        .map_err(|e| format!("Failed to evaluate dependent logic '{}': {}", eval_key, e))?;
1319                    Ok(result)
1320                } else {
1321                    // It's a regular string value
1322                    Ok(value.clone())
1323                }
1324            }
1325            // For backwards compatibility: compile $evaluation on-the-fly
1326            // This shouldn't happen with properly parsed schemas
1327            Value::Object(map) if map.contains_key("$evaluation") => {
1328                Err("Dependent evaluation contains unparsed $evaluation - schema was not properly parsed".to_string())
1329            }
1330            // Primitive value - return as-is
1331            _ => Ok(value.clone()),
1332        }
1333    }
1334
1335    /// Check if a single field is readonly and populate vectors for both changes and all values
1336    pub(crate) fn check_readonly_for_dependents(
1337        &self,
1338        schema_element: &Value,
1339        path: &str,
1340        changes: &mut Vec<(String, Value)>,
1341        all_values: &mut Vec<(String, Value)>,
1342    ) {
1343        match schema_element {
1344            Value::Object(map) => {
1345                // Check if field is disabled (ReadOnly)
1346                let mut is_disabled = false;
1347                if let Some(Value::Object(condition)) = map.get("condition") {
1348                    if let Some(Value::Bool(d)) = condition.get("disabled") {
1349                        is_disabled = *d;
1350                    }
1351                }
1352
1353                // Check skipReadOnlyValue config
1354                let mut skip_readonly = false;
1355                if let Some(Value::Object(config)) = map.get("config") {
1356                    if let Some(Value::Object(all)) = config.get("all") {
1357                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
1358                            skip_readonly = *skip;
1359                        }
1360                    }
1361                }
1362
1363                if is_disabled && !skip_readonly {
1364                    if let Some(schema_value) = map.get("value") {
1365                        let data_path = path_utils::schema_path_to_data_pointer(path)
1366                            // Strip the schema /value/ wrapper that appears in subform array item
1367                            // paths, e.g. #/riders/value/0/sa → /riders/0/sa (correct data pointer).
1368                            .replace("/value/", "/");
1369
1370                        let current_data = self
1371                            .eval_data
1372                            .data()
1373                            .pointer(&data_path)
1374                            .unwrap_or(&Value::Null);
1375
1376                        // Emit to all_values regardless of change (frontend needs $readonly value);
1377                        // add to changes only when eval_data differs from the schema value.
1378                        all_values.push((path.to_string(), schema_value.clone()));
1379                        if current_data != schema_value {
1380                            changes.push((path.to_string(), schema_value.clone()));
1381                        }
1382                    }
1383                }
1384            }
1385            _ => {}
1386        }
1387    }
1388
1389    /// Recursively collect read-only fields that need updates (Legacy/Full-Scan)
1390    #[allow(dead_code)]
1391    pub(crate) fn collect_readonly_fixes(
1392        &self,
1393        schema_element: &Value,
1394        path: &str,
1395        changes: &mut Vec<(String, Value)>,
1396    ) {
1397        match schema_element {
1398            Value::Object(map) => {
1399                // Check if field is disabled (ReadOnly)
1400                let mut is_disabled = false;
1401                if let Some(Value::Object(condition)) = map.get("condition") {
1402                    if let Some(Value::Bool(d)) = condition.get("disabled") {
1403                        is_disabled = *d;
1404                    }
1405                }
1406
1407                // Check skipReadOnlyValue config
1408                let mut skip_readonly = false;
1409                if let Some(Value::Object(config)) = map.get("config") {
1410                    if let Some(Value::Object(all)) = config.get("all") {
1411                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
1412                            skip_readonly = *skip;
1413                        }
1414                    }
1415                }
1416
1417                if is_disabled && !skip_readonly {
1418                    // Check if it's a value field (has "value" property or implicit via path?)
1419                    // In JS: "const readOnlyValues = this.getSchemaValues();"
1420                    // We only care if data != schema value
1421                    if let Some(schema_value) = map.get("value") {
1422                        let data_path = path_utils::schema_path_to_data_pointer(path).into_owned();
1423
1424                        let current_data = self
1425                            .eval_data
1426                            .data()
1427                            .pointer(&data_path)
1428                            .unwrap_or(&Value::Null);
1429
1430                        if current_data != schema_value {
1431                            changes.push((path.to_string(), schema_value.clone()));
1432                        }
1433                    }
1434                }
1435
1436                // Recurse into properties
1437                if let Some(Value::Object(props)) = map.get("properties") {
1438                    for (key, val) in props {
1439                        let next_path = if path == "#" {
1440                            format!("#/properties/{}", key)
1441                        } else {
1442                            format!("{}/properties/{}", path, key)
1443                        };
1444                        self.collect_readonly_fixes(val, &next_path, changes);
1445                    }
1446                }
1447            }
1448            _ => {}
1449        }
1450    }
1451
1452    /// Check if a single field is hidden and needs clearing (Optimized non-recursive)
1453    pub(crate) fn check_hidden_field(
1454        &self,
1455        schema_element: &Value,
1456        path: &str,
1457        hidden_fields: &mut Vec<String>,
1458    ) {
1459        match schema_element {
1460            Value::Object(map) => {
1461                // Check if field is hidden
1462                let mut is_hidden = false;
1463                if let Some(Value::Object(condition)) = map.get("condition") {
1464                    if let Some(Value::Bool(h)) = condition.get("hidden") {
1465                        is_hidden = *h;
1466                    }
1467                }
1468
1469                // Check keepHiddenValue config
1470                let mut keep_hidden = false;
1471                if let Some(Value::Object(config)) = map.get("config") {
1472                    if let Some(Value::Object(all)) = config.get("all") {
1473                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
1474                            keep_hidden = *keep;
1475                        }
1476                    }
1477                }
1478
1479                if is_hidden && !keep_hidden {
1480                    let data_path = path_utils::schema_path_to_data_pointer(path).into_owned();
1481
1482                    let current_data = self
1483                        .eval_data
1484                        .data()
1485                        .pointer(&data_path)
1486                        .unwrap_or(&Value::Null);
1487
1488                    // If hidden and has non-empty value, add to list
1489                    if current_data != &Value::Null && current_data != "" {
1490                        hidden_fields.push(path.to_string());
1491                    }
1492                }
1493            }
1494            _ => {}
1495        }
1496    }
1497
1498    /// Check a field hidden by layout ancestry and needing data clearing.
1499    fn check_effectively_hidden_field(
1500        &self,
1501        schema_element: &Value,
1502        path: &str,
1503        hidden_fields: &mut Vec<String>,
1504    ) {
1505        let Value::Object(map) = schema_element else {
1506            return;
1507        };
1508
1509        let keep_hidden = map
1510            .get("config")
1511            .and_then(Value::as_object)
1512            .and_then(|config| config.get("all"))
1513            .and_then(Value::as_object)
1514            .and_then(|all| all.get("keepHiddenValue"))
1515            .and_then(Value::as_bool)
1516            .unwrap_or(false);
1517        if keep_hidden {
1518            return;
1519        }
1520
1521        let current_data = self
1522            .eval_data
1523            .data()
1524            .pointer(&path_utils::schema_path_to_data_pointer(path))
1525            .unwrap_or(&Value::Null);
1526        if current_data != &Value::Null && current_data != "" {
1527            hidden_fields.push(path.to_string());
1528        }
1529    }
1530
1531    /// Recursively collect hidden fields that have values (candidates for clearing) (Legacy/Full-Scan)
1532    #[allow(dead_code)]
1533    pub(crate) fn collect_hidden_fields(
1534        &self,
1535        schema_element: &Value,
1536        path: &str,
1537        hidden_fields: &mut Vec<String>,
1538    ) {
1539        match schema_element {
1540            Value::Object(map) => {
1541                // Check if field is hidden
1542                let mut is_hidden = false;
1543                if let Some(Value::Object(condition)) = map.get("condition") {
1544                    if let Some(Value::Bool(h)) = condition.get("hidden") {
1545                        is_hidden = *h;
1546                    }
1547                }
1548
1549                // Check keepHiddenValue config
1550                let mut keep_hidden = false;
1551                if let Some(Value::Object(config)) = map.get("config") {
1552                    if let Some(Value::Object(all)) = config.get("all") {
1553                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
1554                            keep_hidden = *keep;
1555                        }
1556                    }
1557                }
1558
1559                if is_hidden && !keep_hidden {
1560                    let data_path = path_utils::schema_path_to_data_pointer(path).into_owned();
1561
1562                    let current_data = self
1563                        .eval_data
1564                        .data()
1565                        .pointer(&data_path)
1566                        .unwrap_or(&Value::Null);
1567
1568                    // If hidden and has non-empty value, add to list
1569                    if current_data != &Value::Null && current_data != "" {
1570                        hidden_fields.push(path.to_string());
1571                    }
1572                }
1573
1574                // Recurse into children
1575                for (key, val) in map {
1576                    if key == "properties" {
1577                        if let Value::Object(props) = val {
1578                            for (p_key, p_val) in props {
1579                                let next_path = if path == "#" {
1580                                    format!("#/properties/{}", p_key)
1581                                } else {
1582                                    format!("{}/properties/{}", path, p_key)
1583                                };
1584                                self.collect_hidden_fields(p_val, &next_path, hidden_fields);
1585                            }
1586                        }
1587                    } else if let Value::Object(_) = val {
1588                        // Skip known metadata keys and explicitly handled keys
1589                        if key == "condition"
1590                            || key == "config"
1591                            || key == "rules"
1592                            || key == "dependents"
1593                            || key == "hideLayout"
1594                            || key == "$layout"
1595                            || key == "$params"
1596                            || key == "definitions"
1597                            || key == "$defs"
1598                            || key.starts_with('$')
1599                        {
1600                            continue;
1601                        }
1602
1603                        let next_path = if path == "#" {
1604                            format!("#/{}", key)
1605                        } else {
1606                            format!("{}/{}", path, key)
1607                        };
1608                        self.collect_hidden_fields(val, &next_path, hidden_fields);
1609                    }
1610                }
1611            }
1612            _ => {}
1613        }
1614    }
1615
1616    /// Perform recursive hiding effect using reffed_by graph.
1617    /// Collects every data path that gets nulled into `invalidated_paths`.
1618    pub(crate) fn recursive_hide_effect(
1619        engine: &RLogic,
1620        evaluations: &IndexMap<String, LogicId>,
1621        reffed_by: &IndexMap<String, Vec<String>>,
1622        eval_data: &mut EvalData,
1623        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1624        mut hidden_fields: Vec<String>,
1625        queue: &mut Vec<(String, bool, Option<Vec<usize>>)>,
1626        result: &mut Vec<Value>,
1627    ) {
1628        while let Some(hf) = hidden_fields.pop() {
1629            let data_path = path_utils::schema_path_to_data_pointer(&hf).into_owned();
1630
1631            // clear data
1632            eval_data.set(&data_path, Value::Null);
1633            eval_cache.bump_data_version(&data_path);
1634
1635            // Create dependent object for result
1636            let mut change_obj = serde_json::Map::new();
1637            change_obj.insert(
1638                "$ref".to_string(),
1639                Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1640            );
1641            change_obj.insert("$hidden".to_string(), Value::Bool(true));
1642            change_obj.insert("clear".to_string(), Value::Bool(true));
1643            result.push(Value::Object(change_obj));
1644
1645            // Add to queue for standard dependent processing
1646            queue.push((hf.clone(), true, None));
1647
1648            // Check reffed_by to find other fields that might become hidden
1649            if let Some(referencing_fields) = reffed_by.get(&data_path) {
1650                for rb in referencing_fields {
1651                    // Evaluate condition.hidden for rb
1652                    // We need a way to run specific evaluation?
1653                    // We can check if rb has a hidden evaluation in self.evaluations
1654                    let hidden_eval_key = format!("{}/condition/hidden", rb);
1655
1656                    if let Some(logic_id) = evaluations.get(&hidden_eval_key) {
1657                        // Run evaluation
1658                        // Context: $value = current field (rb) value? No, $value usually refers to changed field in deps.
1659                        // But here we are just re-evaluating the rule.
1660                        // In JS logic: "const result = hiddenFn(runnerCtx);"
1661                        // runnerCtx has the updated data (we just set hf to null).
1662
1663                        let rb_data_path = path_utils::schema_path_to_data_pointer(rb).into_owned();
1664                        let rb_value = eval_data
1665                            .data()
1666                            .pointer(&rb_data_path)
1667                            .cloned()
1668                            .unwrap_or(Value::Null);
1669
1670                        // We can use engine.run w/ eval_data
1671                        if let Ok(Value::Bool(is_hidden)) = engine.run(logic_id, eval_data.data()) {
1672                            if is_hidden {
1673                                // Check if rb is not already in hidden_fields and has value
1674                                // rb is &String, hidden_fields is Vec<String>
1675                                if !hidden_fields.contains(rb) {
1676                                    let has_value = rb_value != Value::Null && rb_value != "";
1677                                    if has_value {
1678                                        hidden_fields.push(rb.clone());
1679                                    }
1680                                }
1681                            }
1682                        }
1683                    }
1684                }
1685            }
1686        }
1687    }
1688
1689    /// Process the dependents queue.
1690    /// Collects every data path written into `eval_data` into `invalidated_paths`.
1691    pub(crate) fn process_dependents_queue(
1692        engine: &RLogic,
1693        evaluations: &IndexMap<String, LogicId>,
1694        eval_data: &mut EvalData,
1695        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1696        dependents_evaluations: &IndexMap<String, Vec<DependentItem>>,
1697        dep_formula_triggers: &IndexMap<String, Vec<(String, usize)>>,
1698        evaluated_schema: &Value,
1699        queue: &mut Vec<(String, bool, Option<Vec<usize>>)>,
1700        processed: &mut std::collections::HashMap<String, Option<std::collections::HashSet<usize>>>,
1701        result: &mut Vec<Value>,
1702        token: Option<&CancellationToken>,
1703        canceled_paths: Option<&mut Vec<String>>,
1704    ) -> Result<(), String> {
1705        while let Some((current_path, is_transitive, target_indices)) = queue.pop() {
1706            if let Some(t) = token {
1707                if t.is_cancelled() {
1708                    if let Some(cp) = canceled_paths {
1709                        cp.push(current_path.clone());
1710                        for (path, _, _) in queue.iter() {
1711                            cp.push(path.clone());
1712                        }
1713                    }
1714                    return Err("Cancelled".to_string());
1715                }
1716            }
1717
1718            let (should_run, indices_to_run) = match processed.get(&current_path) {
1719                Some(None) => {
1720                    // Already fully processed, skip
1721                    continue;
1722                }
1723                Some(Some(already_processed_indices)) => {
1724                    if let Some(targets) = &target_indices {
1725                        let new_targets: std::collections::HashSet<usize> = targets
1726                            .iter()
1727                            .copied()
1728                            .filter(|i| !already_processed_indices.contains(i))
1729                            .collect();
1730                        if new_targets.is_empty() {
1731                            continue;
1732                        }
1733                        (true, Some(new_targets))
1734                    } else {
1735                        (true, None)
1736                    }
1737                }
1738                None => (
1739                    true,
1740                    target_indices.clone().map(|t| t.into_iter().collect()),
1741                ),
1742            };
1743
1744            if !should_run {
1745                continue;
1746            }
1747
1748            let new_processed_state = if let Some(targets_to_run) = &indices_to_run {
1749                match processed.get(&current_path) {
1750                    Some(Some(existing_targets)) => {
1751                        let mut copy = existing_targets.clone();
1752                        for t in targets_to_run {
1753                            copy.insert(*t);
1754                        }
1755                        Some(copy)
1756                    }
1757                    _ => Some(targets_to_run.clone()),
1758                }
1759            } else {
1760                None
1761            };
1762            processed.insert(current_path.clone(), new_processed_state);
1763
1764            // Get the value of the changed field for $value context
1765            let current_data_path =
1766                path_utils::schema_path_to_data_pointer(&current_path).into_owned();
1767            let mut current_value = eval_data
1768                .data()
1769                .pointer(&current_data_path)
1770                .cloned()
1771                .unwrap_or(Value::Null);
1772
1773            // Re-enqueue source fields whose dependent formulas reference this changed field.
1774            // These are fields that have a dependent formula that checks `current_path` as a
1775            // contextual condition (e.g., ins_occ's formula for ph_occupation checks phins_relation).
1776            // When `current_path` changes, we need to re-evaluate those source fields' dependents.
1777            if let Some(formula_sources) = dep_formula_triggers.get(&current_data_path) {
1778                let mut targets_by_source: std::collections::HashMap<String, Vec<usize>> =
1779                    std::collections::HashMap::new();
1780                for (source_schema_path, dep_idx) in formula_sources {
1781                    let source_ptr = path_utils::dot_notation_to_schema_pointer(source_schema_path);
1782                    targets_by_source
1783                        .entry(source_ptr)
1784                        .or_default()
1785                        .push(*dep_idx);
1786                }
1787                for (source_ptr, targets) in targets_by_source {
1788                    // Check if it's already entirely processed
1789                    if let Some(None) = processed.get(&source_ptr) {
1790                        continue;
1791                    }
1792                    queue.push((source_ptr, true, Some(targets)));
1793                }
1794            }
1795
1796            // Find dependents for this path
1797            if let Some(dependent_items) = dependents_evaluations.get(&current_path) {
1798                for (dep_idx, dep_item) in dependent_items.iter().enumerate() {
1799                    if let Some(targets) = &indices_to_run {
1800                        if !targets.contains(&dep_idx) {
1801                            continue;
1802                        }
1803                    }
1804                    let ref_path = &dep_item.ref_path;
1805
1806                    // Skip writing back to a field that has already been processed.
1807                    // This prevents formula-triggered re-enqueues from creating circular writes:
1808                    // e.g., ins_gender → triggers phins_relation (via dep_formula_triggers) →
1809                    // phins_relation has a dep that writes back to ins_gender → we must not let that happen.
1810                    if processed.contains_key(ref_path) {
1811                        continue;
1812                    }
1813
1814                    let pointer_path = path_utils::normalize_to_json_pointer(ref_path);
1815                    // Data paths don't include /properties/, strip it for data access
1816                    let data_path =
1817                        crate::jsoneval::path_utils::schema_path_to_data_pointer(&pointer_path)
1818                            .into_owned();
1819
1820                    let current_ref_value = eval_data
1821                        .data()
1822                        .pointer(&data_path)
1823                        .cloned()
1824                        .unwrap_or(Value::Null);
1825
1826                    let mut add_transitive = false;
1827                    let mut add_deps = false;
1828                    let mut clear_applied = false;
1829                    let mut value_to_apply = None;
1830
1831                    // Process clear
1832                    if let Some(clear_val) = &dep_item.clear {
1833                        let should_clear = Self::evaluate_dependent_value_static(
1834                            engine,
1835                            evaluations,
1836                            eval_data,
1837                            clear_val,
1838                            &current_value,
1839                            &current_ref_value,
1840                        )?;
1841                        let clear_bool = match should_clear {
1842                            Value::Bool(b) => b,
1843                            _ => false,
1844                        };
1845
1846                        if clear_bool {
1847                            if data_path == current_data_path {
1848                                current_value = Value::Null;
1849                            }
1850                            eval_data.set(&data_path, Value::Null);
1851                            eval_cache.bump_data_version(&data_path);
1852                            clear_applied = true;
1853                            add_transitive = true;
1854                            add_deps = true;
1855                        }
1856                    }
1857
1858                    // Process value
1859                    if let Some(value_val) = &dep_item.value {
1860                        let computed_value = Self::evaluate_dependent_value_static(
1861                            engine,
1862                            evaluations,
1863                            eval_data,
1864                            value_val,
1865                            &current_value,
1866                            &current_ref_value,
1867                        )?;
1868                        let cleaned_val = clean_float_noise_scalar(computed_value);
1869
1870                        let is_clear =
1871                            cleaned_val == Value::Null || cleaned_val.as_str() == Some("");
1872
1873                        if cleaned_val != current_ref_value && !is_clear {
1874                            if data_path == current_data_path {
1875                                current_value = cleaned_val.clone();
1876                            }
1877                            eval_data.set(&data_path, cleaned_val.clone());
1878                            eval_cache.bump_data_version(&data_path);
1879                            value_to_apply = Some(cleaned_val);
1880                            add_transitive = true;
1881                            add_deps = true;
1882                        }
1883                    }
1884
1885                    // add only when has clear / value
1886                    if add_deps {
1887                        let field = evaluated_schema.pointer(&pointer_path).cloned();
1888
1889                        // Get parent field - skip /properties/ to get actual parent object
1890                        let parent_path = if let Some(last_slash) = pointer_path.rfind("/properties") {
1891                            &pointer_path[..last_slash]
1892                        } else {
1893                            "/"
1894                        };
1895                        let parent_field = extract_parent_field(evaluated_schema, parent_path);
1896
1897                        let mut change_obj = serde_json::Map::new();
1898                        change_obj.insert(
1899                            "$ref".to_string(),
1900                            Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1901                        );
1902                        if let Some(f) = field {
1903                            change_obj.insert("$field".to_string(), f);
1904                        }
1905                        change_obj.insert("$parentField".to_string(), parent_field);
1906                        change_obj.insert("transitive".to_string(), Value::Bool(is_transitive));
1907                        if clear_applied {
1908                            change_obj.insert("clear".to_string(), Value::Bool(true));
1909                        }
1910                        if let Some(val) = value_to_apply {
1911                            change_obj.insert("value".to_string(), val);
1912                        }
1913                        result.push(Value::Object(change_obj));
1914                    }
1915
1916                    // Add this dependent to queue for transitive processing
1917                    if add_transitive {
1918                        queue.push((ref_path.clone(), true, None));
1919                    }
1920                }
1921            }
1922        }
1923        Ok(())
1924    }
1925}
1926
1927/// Extract the field key from a subform path.
1928///
1929/// Examples:
1930/// - `#/riders`                               → `riders`
1931/// - `#/properties/form/properties/riders`    → `riders`
1932/// - `#/items`                                → `items`
1933fn subform_field_key(subform_path: &str) -> String {
1934    // Strip leading `#/`
1935    let stripped = subform_path.trim_start_matches('#').trim_start_matches('/');
1936
1937    // The last non-"properties" segment is the field key
1938    stripped
1939        .split('/')
1940        .filter(|seg| !seg.is_empty() && *seg != "properties")
1941        .last()
1942        .unwrap_or(stripped)
1943        .to_string()
1944}
1945
1946/// Extract the parent field definition excluding heavy child collections (`properties`, `$layout`).
1947/// Returns a shallow copy of the parent schema node's attributes.
1948fn extract_parent_field(evaluated_schema: &Value, parent_path: &str) -> Value {
1949    let node = if parent_path.is_empty() || parent_path == "/" {
1950        evaluated_schema
1951    } else {
1952        match evaluated_schema.pointer(parent_path) {
1953            Some(v) => v,
1954            None => return Value::Object(serde_json::Map::new()),
1955        }
1956    };
1957    if let Value::Object(map) = node {
1958        let mut filtered = serde_json::Map::with_capacity(map.len().saturating_sub(2));
1959        for (k, v) in map {
1960            if k != "properties" && k != "$layout" {
1961                filtered.insert(k.clone(), v.clone());
1962            }
1963        }
1964        Value::Object(filtered)
1965    } else {
1966        Value::Object(serde_json::Map::new())
1967    }
1968}