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