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