Skip to main content

json_eval_rs/jsoneval/
dependents.rs

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