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