Skip to main content

json_eval_rs/jsoneval/
dependents.rs

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