Skip to main content

json_eval_rs/jsoneval/
dependents.rs

1use super::JSONEval;
2use crate::jsoneval::cancellation::CancellationToken;
3use crate::jsoneval::json_parser;
4use crate::jsoneval::path_utils;
5use crate::jsoneval::path_utils::get_value_by_pointer_without_properties;
6use crate::jsoneval::path_utils::normalize_to_json_pointer;
7use crate::jsoneval::types::DependentItem;
8use crate::rlogic::{LogicId, RLogic};
9use crate::time_block;
10use crate::utils::clean_float_noise_scalar;
11use crate::EvalData;
12
13use indexmap::{IndexMap, IndexSet};
14use serde_json::Value;
15
16impl JSONEval {
17    /// Evaluate fields that depend on a changed path.
18    /// Processes all dependent fields transitively, then optionally performs a full
19    /// re-evaluation pass (for read-only / hide effects) and cascades into subforms.
20    pub fn evaluate_dependents(
21        &mut self,
22        changed_paths: &[String],
23        data: Option<&str>,
24        context: Option<&str>,
25        re_evaluate: bool,
26        token: Option<&CancellationToken>,
27        mut canceled_paths: Option<&mut Vec<String>>,
28        include_subforms: bool,
29    ) -> Result<Value, String> {
30        if let Some(t) = token {
31            if t.is_cancelled() {
32                return Err("Cancelled".to_string());
33            }
34        }
35        let _lock = self.eval_lock.lock().unwrap();
36        let mut structural_change_data = None;
37
38        // Update data if provided, diff versions
39        if let Some(data_str) = data {
40            let data_value = json_parser::parse_json_str(data_str)?;
41            let context_value = if let Some(ctx) = context {
42                json_parser::parse_json_str(ctx)?
43            } else {
44                Value::Object(serde_json::Map::new())
45            };
46            let old_data = self.eval_data.snapshot_data_clone();
47            time_block!("  [dep] data_replace_and_context", {
48                self.eval_data
49                    .replace_data_and_context(data_value, context_value);
50            });
51            let new_data = self.eval_data.snapshot_data_clone();
52            time_block!("  [dep] data_diff_versions", {
53                self.eval_cache
54                    .store_snapshot_and_diff_versions(&old_data, &new_data);
55            });
56            structural_change_data = Some((old_data, new_data));
57        }
58
59        // Drop the lock before calling sub-methods that need &mut self
60        drop(_lock);
61
62        // When a subform array changes structurally (riders added/removed/reordered),
63        // evict stale T2 global cache entries whose dep paths use the subform-local key
64        // format that is never bumped by the parent-level diff.
65        if let Some((old_data, new_data)) = structural_change_data {
66            time_block!("  [dep] invalidate_subform_structural", {
67                self.invalidate_subform_caches_on_structural_change(&old_data, &new_data);
68            });
69        }
70
71        let mut result = Vec::new();
72        let mut processed = std::collections::HashMap::new();
73        let mut to_process: Vec<(String, bool, Option<Vec<usize>>)> = changed_paths
74            .iter()
75            .map(|path| {
76                (
77                    path_utils::dot_notation_to_schema_pointer(path),
78                    false,
79                    None,
80                )
81            })
82            .collect();
83
84        time_block!("  [dep] process_dependents_queue", {
85            Self::process_dependents_queue(
86                &self.engine,
87                &self.evaluations,
88                &mut self.eval_data,
89                &mut self.eval_cache,
90                &self.dependents_evaluations,
91                &self.dep_formula_triggers,
92                &self.evaluated_schema,
93                &mut to_process,
94                &mut processed,
95                &mut result,
96                token,
97                canceled_paths.as_mut().map(|v| &mut **v),
98            )?;
99        });
100
101        if re_evaluate {
102            time_block!("  [dep] run_re_evaluate_pass", {
103                self.run_re_evaluate_pass(
104                    token,
105                    &mut to_process,
106                    &mut processed,
107                    &mut result,
108                    canceled_paths.as_mut().map(|v| &mut **v),
109                )?;
110            });
111        }
112
113        if include_subforms {
114            // Augment changed_paths with every subform item field already written into result
115            // by the dependents queue and re-evaluate pass. Without this, when a main-form
116            // dependent rule writes to e.g. `riders.0.benefit`, that path never appears in
117            // `item_changed_paths` inside run_subform_pass → the item is skipped → the
118            // subform item's own `benefit.dependents` never fire.
119            let extended_paths: Vec<String> = {
120                let mut paths = changed_paths.to_vec();
121                for item in &result {
122                    if let Some(ref_val) = item.get("$ref").and_then(|v| v.as_str()) {
123                        let s = ref_val.to_string();
124                        if !paths.contains(&s) {
125                            paths.push(s);
126                        }
127                    }
128                }
129                paths
130            };
131            time_block!("  [dep] run_subform_pass", {
132                self.run_subform_pass(&extended_paths, re_evaluate, token, &mut result)?;
133            });
134        }
135
136        // Deduplicate by $ref — keep the last entry for each path.
137        // Multiple passes (dependents queue, re-evaluate, subform) may independently emit
138        // the same $ref when cache versions cause overlapping detections. The subform pass
139        // result is most specific and wins because it is appended last.
140        let deduped = {
141            let mut seen: IndexMap<String, usize> = IndexMap::new();
142            for (i, item) in result.iter().enumerate() {
143                if let Some(r) = item.get("$ref").and_then(|v| v.as_str()) {
144                    seen.insert(r.to_string(), i);
145                }
146            }
147            let last_indices: IndexSet<usize> = seen.values().copied().collect();
148            let out: Vec<Value> = result
149                .into_iter()
150                .enumerate()
151                .filter(|(i, _)| last_indices.contains(i))
152                .map(|(_, item)| item)
153                .collect();
154            out
155        };
156
157        // Refresh main_form_snapshot so the next evaluate() call computes diffs from the
158        // post-dependents state instead of the old pre-dependents snapshot.
159        // Without this, evaluate() re-diffs every field that evaluate_dependents already
160        // processed, double-bumping data_versions and causing spurious cache misses in
161        // evaluate_internal (observed as unexpected ~550ms "cache hit" full evaluates).
162        // Only update when no subform item is active — subform evaluate_dependents calls
163        // must not overwrite the parent's snapshot.
164        if self.eval_cache.active_item_index.is_none() {
165            let current_snapshot = self.eval_data.snapshot_data_clone();
166            self.eval_cache.main_form_snapshot = Some(current_snapshot);
167        }
168
169        Ok(Value::Array(deduped))
170    }
171
172    /// Full re-evaluation pass: runs `evaluate_internal`, then applies read-only fixes and
173    /// recursive hide effects, feeding any newly-generated changes back into the dependents queue.
174    fn run_re_evaluate_pass(
175        &mut self,
176        token: Option<&CancellationToken>,
177        to_process: &mut Vec<(String, bool, Option<Vec<usize>>)>,
178        processed: &mut std::collections::HashMap<String, Option<std::collections::HashSet<usize>>>,
179        result: &mut Vec<Value>,
180        mut canceled_paths: Option<&mut Vec<String>>,
181    ) -> Result<(), String> {
182        // --- Schema Default Value Pass (Before Eval) ---
183        self.run_schema_default_value_pass(
184            token,
185            to_process,
186            processed,
187            result,
188            canceled_paths.as_mut().map(|v| &mut **v),
189        )?;
190
191        // Resolve the correct data_versions tracker before snapshotting.
192        // When active_item_index is Some(idx), evaluate_internal bumps
193        // subform_caches[idx].data_versions — NOT the main data_versions.
194        // Using the main tracker for both snapshot and post-eval lookup would make
195        // old_ver == new_ver always, so no changed values would ever be emitted.
196        let pre_eval_versions = if let Some(idx) = self.eval_cache.active_item_index {
197            self.eval_cache
198                .subform_caches
199                .get(&idx)
200                .map(|c| c.data_versions.clone())
201                .unwrap_or_else(|| self.eval_cache.data_versions.clone())
202        } else {
203            self.eval_cache.data_versions.clone()
204        };
205
206        self.evaluate_internal(None, token)?;
207
208        // --- Schema Default Value Pass (After Eval) ---
209        self.run_schema_default_value_pass(
210            token,
211            to_process,
212            processed,
213            result,
214            canceled_paths.as_mut().map(|v| &mut **v),
215        )?;
216
217        // Emit result entries for every sorted-evaluation whose version uniquely bumped.
218        let active_idx = self.eval_cache.active_item_index;
219        for eval_key in self.sorted_evaluations.iter().flatten() {
220            if eval_key.contains("/$params/") || eval_key.contains("/$") {
221                continue;
222            }
223
224            let schema_ptr = path_utils::normalize_to_json_pointer(eval_key);
225            let data_path = schema_ptr
226                .replace("/properties/", "/")
227                .trim_start_matches('#')
228                .trim_start_matches('/')
229                .to_string();
230
231            let version_path = format!("/{}", data_path);
232            let old_ver = pre_eval_versions.get(&version_path);
233            let new_ver = if let Some(idx) = active_idx {
234                self.eval_cache
235                    .subform_caches
236                    .get(&idx)
237                    .map(|c| c.data_versions.get(&version_path))
238                    .unwrap_or_else(|| self.eval_cache.data_versions.get(&version_path))
239            } else {
240                self.eval_cache.data_versions.get(&version_path)
241            };
242
243            if new_ver > old_ver {
244                if let Some(new_val) = self.evaluated_schema.pointer(&schema_ptr) {
245                    let dot_path = data_path.trim_end_matches("/value").replace('/', ".");
246                    let mut obj = serde_json::Map::new();
247                    obj.insert("$ref".to_string(), Value::String(dot_path));
248                    let is_clear = new_val == &Value::Null || new_val.as_str() == Some("");
249                    if is_clear {
250                        obj.insert("clear".to_string(), Value::Bool(true));
251                    } else {
252                        obj.insert("value".to_string(), new_val.clone());
253                    }
254                    result.push(Value::Object(obj));
255                }
256            }
257        }
258
259        // Re-acquire lock for post-eval passes
260        let _lock = self.eval_lock.lock().unwrap();
261
262        // --- Read-Only Pass ---
263        let mut readonly_changes = Vec::new();
264        let mut readonly_values = Vec::new();
265        for path in self.conditional_readonly_fields.iter() {
266            let normalized = path_utils::normalize_to_json_pointer(path);
267            if let Some(schema_el) = self.evaluated_schema.pointer(&normalized) {
268                self.check_readonly_for_dependents(
269                    schema_el,
270                    path,
271                    &mut readonly_changes,
272                    &mut readonly_values,
273                );
274            }
275        }
276        // Capture count before drain so had_actual_readonly_changes is not confused by
277        // pre-existing to_process entries from schema_default_before or process_dependents_queue.
278        let had_actual_readonly_changes = !readonly_changes.is_empty();
279        for (path, schema_value) in readonly_changes {
280            let data_path = path_utils::normalize_to_json_pointer(&path)
281                .replace("/properties/", "/")
282                .trim_start_matches('#')
283                .to_string();
284            self.eval_data.set(&data_path, schema_value.clone());
285            self.eval_cache.bump_data_version(&data_path);
286            to_process.push((path, true, None));
287        }
288        for (path, schema_value) in readonly_values {
289            let data_path = path_utils::normalize_to_json_pointer(&path)
290                .replace("/properties/", "/")
291                .trim_start_matches('#')
292                .to_string();
293            let mut obj = serde_json::Map::new();
294            obj.insert(
295                "$ref".to_string(),
296                Value::String(path_utils::pointer_to_dot_notation(&data_path)),
297            );
298            obj.insert("$readonly".to_string(), Value::Bool(true));
299            let is_clear = schema_value == Value::Null || schema_value.as_str() == Some("");
300            if is_clear {
301                obj.insert("clear".to_string(), Value::Bool(true));
302            } else {
303                obj.insert("value".to_string(), schema_value);
304            }
305            result.push(Value::Object(obj));
306        }
307
308        // When readonly fields were updated, re-run evaluate_internal only for the subset of
309        // $params tables that actually depend on the changed readonly fields.
310        // Use had_actual_readonly_changes (captured before draining into to_process) — NOT
311        // `!to_process.is_empty()`, which would fire whenever any dependents were queued,
312        // triggering a spurious second evaluate_internal per rider costing ~240ms each.
313        if had_actual_readonly_changes {
314            if let Some(active_idx) = self.eval_cache.active_item_index {
315                // Collect the schema-dep paths for the readonly-changed fields so we can
316                // filter tables to only those that actually depend on these fields.
317                // Bumping ALL $params tables unconditionally forces a full re-evaluate of
318                // every WOP/RIDER table even when they don't read wop_rider_premi etc.
319                let readonly_dep_prefixes: Vec<String> =
320                    to_process.iter().map(|(path, _, _)| path.clone()).collect();
321
322                let params_table_keys: Vec<String> = self
323                    .table_metadata
324                    .keys()
325                    .filter(|k| {
326                        if !k.starts_with("#/$params") {
327                            return false;
328                        }
329                        // Only invalidate tables that depend on one of the readonly fields
330                        if let Some(deps) = self.dependencies.get(*k) {
331                            deps.iter().any(|dep| {
332                                readonly_dep_prefixes
333                                    .iter()
334                                    .any(|ro| dep == ro || dep.starts_with(ro.as_str()))
335                            })
336                        } else {
337                            false
338                        }
339                    })
340                    .cloned()
341                    .collect();
342
343                if !params_table_keys.is_empty() {
344                    self.eval_cache
345                        .invalidate_params_tables_for_item(active_idx, &params_table_keys);
346                    drop(_lock);
347                    self.evaluate_internal(None, token)?;
348                }
349            }
350        }
351
352        if !to_process.is_empty() {
353            Self::process_dependents_queue(
354                &self.engine,
355                &self.evaluations,
356                &mut self.eval_data,
357                &mut self.eval_cache,
358                &self.dependents_evaluations,
359                &self.dep_formula_triggers,
360                &self.evaluated_schema,
361                to_process,
362                processed,
363                result,
364                token,
365                canceled_paths.as_mut().map(|v| &mut **v),
366            )?;
367        }
368
369        // --- Recursive Hide Pass ---
370        let mut hidden_fields = Vec::new();
371        for path in self.conditional_hidden_fields.iter() {
372            let normalized = path_utils::normalize_to_json_pointer(path);
373            if let Some(schema_el) = self.evaluated_schema.pointer(&normalized) {
374                self.check_hidden_field(schema_el, path, &mut hidden_fields);
375            }
376        }
377        if !hidden_fields.is_empty() {
378            Self::recursive_hide_effect(
379                &self.engine,
380                &self.evaluations,
381                &self.reffed_by,
382                &mut self.eval_data,
383                &mut self.eval_cache,
384                hidden_fields,
385                to_process,
386                result,
387            );
388        }
389        if !to_process.is_empty() {
390            Self::process_dependents_queue(
391                &self.engine,
392                &self.evaluations,
393                &mut self.eval_data,
394                &mut self.eval_cache,
395                &self.dependents_evaluations,
396                &self.dep_formula_triggers,
397                &self.evaluated_schema,
398                to_process,
399                processed,
400                result,
401                token,
402                canceled_paths.as_mut().map(|v| &mut **v),
403            )?;
404        }
405
406        Ok(())
407    }
408
409    /// Internal method to run the schema default value pass.
410    /// Filters for only primitive schema values (not $evaluation objects).
411    fn run_schema_default_value_pass(
412        &mut self,
413        token: Option<&CancellationToken>,
414        to_process: &mut Vec<(String, bool, Option<Vec<usize>>)>,
415        processed: &mut std::collections::HashMap<String, Option<std::collections::HashSet<usize>>>,
416        result: &mut Vec<Value>,
417        mut canceled_paths: Option<&mut Vec<String>>,
418    ) -> Result<(), String> {
419        let mut default_value_changes = Vec::new();
420        let schema_values = self.get_schema_value_array();
421
422        if let Value::Array(values) = schema_values {
423            for item in values {
424                if let Value::Object(map) = item {
425                    if let (Some(Value::String(dot_path)), Some(schema_val)) =
426                        (map.get("path"), map.get("value"))
427                    {
428                        let schema_ptr = path_utils::dot_notation_to_schema_pointer(dot_path);
429                        if let Some(Value::Object(schema_node)) = self
430                            .evaluated_schema
431                            .pointer(schema_ptr.trim_start_matches('#'))
432                        {
433                            if let Some(Value::Object(condition)) = schema_node.get("condition") {
434                                if let Some(hidden_val) = condition.get("hidden") {
435                                    // Skip if hidden is true OR if it's a non-primitive value (formula object)
436                                    if !hidden_val.is_boolean()
437                                        || hidden_val.as_bool() == Some(true)
438                                    {
439                                        continue;
440                                    }
441                                }
442                            }
443                        }
444
445                        let data_path = dot_path.replace('.', "/");
446                        let current_data = self
447                            .eval_data
448                            .data()
449                            .pointer(&format!("/{}", data_path))
450                            .unwrap_or(&Value::Null);
451
452                        let is_empty = match current_data {
453                            Value::Null => true,
454                            Value::String(s) if s.is_empty() => true,
455                            _ => false,
456                        };
457
458                        let is_schema_val_empty = match schema_val {
459                            Value::Null => true,
460                            Value::String(s) if s.is_empty() => true,
461                            Value::Object(map) if map.contains_key("$evaluation") => true,
462                            _ => false,
463                        };
464
465                        if is_empty && !is_schema_val_empty && current_data != schema_val {
466                            default_value_changes.push((
467                                data_path,
468                                schema_val.clone(),
469                                dot_path.clone(),
470                            ));
471                        }
472                    }
473                }
474            }
475        }
476
477        let mut has_changes = false;
478        for (data_path, schema_val, dot_path) in default_value_changes {
479            self.eval_data
480                .set(&format!("/{}", data_path), schema_val.clone());
481            self.eval_cache
482                .bump_data_version(&format!("/{}", data_path));
483
484            let mut change_obj = serde_json::Map::new();
485            change_obj.insert("$ref".to_string(), Value::String(dot_path));
486            let is_clear = schema_val == Value::Null || schema_val.as_str() == Some("");
487            if is_clear {
488                change_obj.insert("clear".to_string(), Value::Bool(true));
489            } else {
490                change_obj.insert("value".to_string(), schema_val);
491            }
492            result.push(Value::Object(change_obj));
493
494            let schema_ptr = format!("#/{}", data_path.replace('/', "/properties/"));
495            to_process.push((schema_ptr, true, None));
496            has_changes = true;
497        }
498
499        if has_changes {
500            Self::process_dependents_queue(
501                &self.engine,
502                &self.evaluations,
503                &mut self.eval_data,
504                &mut self.eval_cache,
505                &self.dependents_evaluations,
506                &self.dep_formula_triggers,
507                &self.evaluated_schema,
508                to_process,
509                processed,
510                result,
511                token,
512                canceled_paths.as_mut().map(|v| &mut **v),
513            )?;
514        }
515
516        Ok(())
517    }
518
519    /// Cascade dependency evaluation into each subform item.
520    ///
521    /// For every registered subform, this method iterates over its array items and runs
522    /// `evaluate_dependents` on the subform using the cache-swap strategy so the subform
523    /// can see global main-form Tier 2 cache entries (avoiding redundant table re-evaluation).
524    ///
525    /// `sub_re_evaluate` is set **only** when the parent's bumped `data_versions` intersect
526    /// with paths the subform actually depends on — preventing expensive full re-evals on
527    /// subform items whose dependencies did not change.
528    fn run_subform_pass(
529        &mut self,
530        changed_paths: &[String],
531        re_evaluate: bool,
532        token: Option<&CancellationToken>,
533        result: &mut Vec<Value>,
534    ) -> Result<(), String> {
535        // Collect subform paths once (avoids holding borrow on self.subforms during mutation)
536        let subform_paths: Vec<String> = self.subforms.keys().cloned().collect();
537
538        for subform_path in subform_paths {
539            let field_key = subform_field_key(&subform_path);
540            // Compute dotted path and prefix strings once per subform, not per item
541            let subform_dot_path =
542                path_utils::pointer_to_dot_notation(&subform_path).replace(".properties.", ".");
543            let field_prefix = format!("{}.", field_key);
544            let subform_ptr = normalize_to_json_pointer(&subform_path);
545
546            // Borrow only the item count first — avoid cloning the full array
547            let item_count =
548                get_value_by_pointer_without_properties(self.eval_data.data(), &subform_ptr)
549                    .and_then(|v| v.as_array())
550                    .map(|a| a.len())
551                    .unwrap_or(0);
552
553            if item_count == 0 {
554                continue;
555            }
556
557            // Evict stale per-item caches for indices that no longer exist in the array.
558            // This prevents memory leaks when riders are removed and the array shrinks.
559            self.eval_cache.prune_subform_caches(item_count);
560
561            // When the parent ran a re_evaluate pass, always pass re_evaluate:true to subforms.
562            // The parent's evaluate_internal may have updated $params or other referenced values
563            // that the subform formulas read, even if none of the subform's own dep paths bumped.
564            let global_sub_re_evaluate = re_evaluate;
565
566            // Snapshot the parent's version trackers once, before iterating any riders.
567            // Using the live `parent_cache.data_versions` inside the loop would let rider N's
568            // evaluation bumps contaminate the merge_from baseline for rider M (M ≠ N),
569            // causing cache misses and wrong re-evaluations on subsequent visits to rider M.
570            let parent_data_versions_snapshot = self.eval_cache.data_versions.clone();
571            let parent_params_versions_snapshot = self.eval_cache.params_versions.clone();
572
573            for idx in 0..item_count {
574                // Map absolute changed paths → subform-internal paths for this item index
575                let prefix_dot = format!("{}.{}.", subform_dot_path, idx);
576                let prefix_bracket = format!("{}[{}].", subform_dot_path, idx);
577                let prefix_field_bracket = format!("{}[{}].", field_key, idx);
578
579                let item_changed_paths: Vec<String> = changed_paths
580                    .iter()
581                    .filter_map(|p| {
582                        if p.starts_with(&prefix_bracket) {
583                            Some(p.replacen(&prefix_bracket, &field_prefix, 1))
584                        } else if p.starts_with(&prefix_dot) {
585                            Some(p.replacen(&prefix_dot, &field_prefix, 1))
586                        } else if p.starts_with(&prefix_field_bracket) {
587                            Some(p.replacen(&prefix_field_bracket, &field_prefix, 1))
588                        } else {
589                            None
590                        }
591                    })
592                    .collect();
593
594                let sub_re_evaluate = global_sub_re_evaluate || !item_changed_paths.is_empty();
595
596                // Skip entirely if there's nothing to do for this item
597                if !sub_re_evaluate && item_changed_paths.is_empty() {
598                    continue;
599                }
600
601                // Build minimal merged data: clone only item at idx, share $params shallowly.
602                // This avoids cloning the full 5MB parent payload for every item.
603                let item_val =
604                    get_value_by_pointer_without_properties(self.eval_data.data(), &subform_ptr)
605                        .and_then(|v| v.as_array())
606                        .and_then(|a| a.get(idx))
607                        .cloned()
608                        .unwrap_or(Value::Null);
609
610                // Build a minimal parent object with only the fields the subform needs:
611                // the item under field_key, plus all non-array top-level parent fields
612                // ($params markers, scalars). Large arrays are already stripped to static_arrays.
613                let merged_data = {
614                    let parent = self.eval_data.data();
615                    let mut map = serde_json::Map::new();
616                    if let Value::Object(parent_map) = parent {
617                        for (k, v) in parent_map {
618                            if k == &field_key {
619                                // Will be overridden with the single item below
620                                continue;
621                            }
622                            // Include scalars, objects ($params markers, etc.) but skip
623                            // other large array fields that aren't this subform
624                            if !v.is_array() {
625                                map.insert(k.clone(), v.clone());
626                            }
627                        }
628                    }
629                    map.insert(field_key.clone(), item_val.clone());
630                    Value::Object(map)
631                };
632
633                let Some(subform) = self.subforms.get_mut(&subform_path) else {
634                    continue;
635                };
636
637                // Prepare cache state for this item
638                self.eval_cache.ensure_active_item_cache(idx);
639                let old_item_val = self
640                    .eval_cache
641                    .subform_caches
642                    .get(&idx)
643                    .map(|c| c.item_snapshot.clone())
644                    .unwrap_or(Value::Null);
645
646                subform.eval_data.replace_data_and_context(
647                    merged_data,
648                    self.eval_data
649                        .data()
650                        .get("$context")
651                        .cloned()
652                        .unwrap_or(Value::Null),
653                );
654                let new_item_val = subform
655                    .eval_data
656                    .data()
657                    .get(&field_key)
658                    .cloned()
659                    .unwrap_or(Value::Null);
660
661                // Cache-swap: lend parent cache to subform
662                let mut parent_cache = std::mem::take(&mut self.eval_cache);
663                parent_cache.ensure_active_item_cache(idx);
664                if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
665                    // Merge all data versions from the parent snapshot. We must include non-$params
666                    // paths so that parent field updates (like wop_basic_benefit changing) correctly
667                    // invalidate subform per-item cache entries that depend on them.
668                    c.data_versions.merge_from(&parent_data_versions_snapshot);
669                    // Always reflect the latest $params (schema-level, index-independent).
670                    c.data_versions
671                        .merge_from_params(&parent_params_versions_snapshot);
672                    crate::jsoneval::eval_cache::diff_and_update_versions(
673                        &mut c.data_versions,
674                        &format!("/{}", field_key),
675                        &old_item_val,
676                        &new_item_val,
677                    );
678                    c.item_snapshot = new_item_val;
679                }
680                parent_cache.set_active_item(idx);
681                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
682
683                let subform_result = time_block!("    [subform_pass] rider evaluate_dependents", {
684                    subform.evaluate_dependents(
685                        &item_changed_paths,
686                        None,
687                        None,
688                        sub_re_evaluate,
689                        token,
690                        None,
691                        false,
692                    )
693                });
694
695                // Restore parent cache
696                std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
697                parent_cache.clear_active_item();
698
699                // Propagate the updated item_snapshot from the parent's T1 cache into the
700                // subform's own eval_cache. Without this, subsequent evaluate_subform() calls
701                // for this idx read the OLD snapshot (pre-run_subform_pass) and see a diff
702                // against the new data → item_paths_bumped = true → spurious table invalidation.
703                if let Some(parent_item_cache) = self.eval_cache.subform_caches.get(&idx) {
704                    let snapshot = parent_item_cache.item_snapshot.clone();
705                    subform.eval_cache.ensure_active_item_cache(idx);
706                    if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
707                        sub_cache.item_snapshot = snapshot;
708                    }
709                }
710
711                self.eval_cache = parent_cache;
712
713                if let Ok(Value::Array(changes)) = subform_result {
714                    let mut had_any_change = false;
715                    for change in changes {
716                        if let Some(obj) = change.as_object() {
717                            if let Some(Value::String(ref_path)) = obj.get("$ref") {
718                                // Remap the $ref path to include the parent path + item index
719                                let new_ref = if ref_path.starts_with(&field_prefix) {
720                                    format!(
721                                        "{}.{}.{}",
722                                        subform_dot_path,
723                                        idx,
724                                        &ref_path[field_prefix.len()..]
725                                    )
726                                } else {
727                                    format!("{}.{}.{}", subform_dot_path, idx, ref_path)
728                                };
729
730                                // Write the computed value back to parent eval_data so subsequent
731                                // evaluate_subform calls see an up-to-date old_item_snapshot.
732                                // Without this, the diff in with_item_cache_swap sees stale parent
733                                // data vs the new call's apply_changes values → spurious item bumps
734                                // → invalidate_params_tables_for_item fires → eval_generation bumps.
735                                if let Some(val) = obj.get("value") {
736                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
737                                    self.eval_data.set(&data_ptr, val.clone());
738                                    had_any_change = true;
739                                } else if obj.get("clear").and_then(Value::as_bool) == Some(true) {
740                                    let data_ptr = format!("/{}", new_ref.replace('.', "/"));
741                                    self.eval_data.set(&data_ptr, Value::Null);
742                                    had_any_change = true;
743                                }
744
745                                let mut new_obj = obj.clone();
746                                new_obj.insert("$ref".to_string(), Value::String(new_ref));
747                                result.push(Value::Object(new_obj));
748                            } else {
749                                // No $ref rewrite needed — push as-is without cloning the map
750                                result.push(change);
751                            }
752                        }
753                    }
754
755                    // After writing computed outputs (first_prem, wop_rider_premi, etc.) back to
756                    // parent eval_data, refresh the item_snapshot so that subsequent evaluate_subform
757                    // calls see the post-computation state as their baseline. Without this, the
758                    // snapshot only contains the raw input (before apply_changes), so the next
759                    // with_item_cache_swap diff detects ALL computed fields (first_prem, code, sa ...)
760                    // as "changed" even when only genuinely-new data arrived, causing spurious
761                    // secondary version bumps → false T2 table misses for RIDER_ZLOB_TABLE etc.
762                    if had_any_change {
763                        let item_path = format!("{}/{}", subform_ptr, idx);
764                        let updated_item = self
765                            .eval_data
766                            .get(&item_path)
767                            .cloned()
768                            .unwrap_or(Value::Null);
769                        // Update parent T1 cache snapshot
770                        if let Some(c) = self.eval_cache.subform_caches.get_mut(&idx) {
771                            c.item_snapshot = updated_item.clone();
772                        }
773                        // Update subform's own per-item snapshot used as old_item_snapshot
774                        // on the next evaluate_subform call.
775                        subform.eval_cache.ensure_active_item_cache(idx);
776                        if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
777                            sub_cache.item_snapshot = updated_item;
778                        }
779                    }
780                }
781            }
782        }
783        Ok(())
784    }
785
786    /// Helper to evaluate a dependent value - uses pre-compiled eval keys for fast lookup
787    pub(crate) fn evaluate_dependent_value_static(
788        engine: &RLogic,
789        evaluations: &IndexMap<String, LogicId>,
790        eval_data: &EvalData,
791        value: &Value,
792        changed_field_value: &Value,
793        changed_field_ref_value: &Value,
794    ) -> Result<Value, String> {
795        match value {
796            // If it's a String, check if it's an eval key reference
797            Value::String(eval_key) => {
798                if let Some(logic_id) = evaluations.get(eval_key) {
799                    // It's a pre-compiled evaluation - run it with scoped context
800                    // Create internal context with $value and $refValue
801                    let mut internal_context = serde_json::Map::new();
802                    internal_context.insert("$value".to_string(), changed_field_value.clone());
803                    internal_context.insert("$refValue".to_string(), changed_field_ref_value.clone());
804                    let context_value = Value::Object(internal_context);
805
806                    let result = engine.run_with_context(logic_id, eval_data.data(), &context_value)
807                        .map_err(|e| format!("Failed to evaluate dependent logic '{}': {}", eval_key, e))?;
808                    Ok(result)
809                } else {
810                    // It's a regular string value
811                    Ok(value.clone())
812                }
813            }
814            // For backwards compatibility: compile $evaluation on-the-fly
815            // This shouldn't happen with properly parsed schemas
816            Value::Object(map) if map.contains_key("$evaluation") => {
817                Err("Dependent evaluation contains unparsed $evaluation - schema was not properly parsed".to_string())
818            }
819            // Primitive value - return as-is
820            _ => Ok(value.clone()),
821        }
822    }
823
824    /// Check if a single field is readonly and populate vectors for both changes and all values
825    pub(crate) fn check_readonly_for_dependents(
826        &self,
827        schema_element: &Value,
828        path: &str,
829        changes: &mut Vec<(String, Value)>,
830        all_values: &mut Vec<(String, Value)>,
831    ) {
832        match schema_element {
833            Value::Object(map) => {
834                // Check if field is disabled (ReadOnly)
835                let mut is_disabled = false;
836                if let Some(Value::Object(condition)) = map.get("condition") {
837                    if let Some(Value::Bool(d)) = condition.get("disabled") {
838                        is_disabled = *d;
839                    }
840                }
841
842                // Check skipReadOnlyValue config
843                let mut skip_readonly = false;
844                if let Some(Value::Object(config)) = map.get("config") {
845                    if let Some(Value::Object(all)) = config.get("all") {
846                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
847                            skip_readonly = *skip;
848                        }
849                    }
850                }
851
852                if is_disabled && !skip_readonly {
853                    if let Some(schema_value) = map.get("value") {
854                        let data_path = path_utils::normalize_to_json_pointer(path)
855                            .replace("/properties/", "/")
856                            .trim_start_matches('#')
857                            .to_string();
858
859                        let current_data = self
860                            .eval_data
861                            .data()
862                            .pointer(&data_path)
863                            .unwrap_or(&Value::Null);
864
865                        // Add to all_values (include in dependents result regardless of change)
866                        all_values.push((path.to_string(), schema_value.clone()));
867
868                        // Only add to changes if value doesn't match
869                        if current_data != schema_value {
870                            changes.push((path.to_string(), schema_value.clone()));
871                        }
872                    }
873                }
874            }
875            _ => {}
876        }
877    }
878
879    /// Recursively collect read-only fields that need updates (Legacy/Full-Scan)
880    #[allow(dead_code)]
881    pub(crate) fn collect_readonly_fixes(
882        &self,
883        schema_element: &Value,
884        path: &str,
885        changes: &mut Vec<(String, Value)>,
886    ) {
887        match schema_element {
888            Value::Object(map) => {
889                // Check if field is disabled (ReadOnly)
890                let mut is_disabled = false;
891                if let Some(Value::Object(condition)) = map.get("condition") {
892                    if let Some(Value::Bool(d)) = condition.get("disabled") {
893                        is_disabled = *d;
894                    }
895                }
896
897                // Check skipReadOnlyValue config
898                let mut skip_readonly = false;
899                if let Some(Value::Object(config)) = map.get("config") {
900                    if let Some(Value::Object(all)) = config.get("all") {
901                        if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
902                            skip_readonly = *skip;
903                        }
904                    }
905                }
906
907                if is_disabled && !skip_readonly {
908                    // Check if it's a value field (has "value" property or implicit via path?)
909                    // In JS: "const readOnlyValues = this.getSchemaValues();"
910                    // We only care if data != schema value
911                    if let Some(schema_value) = map.get("value") {
912                        let data_path = path_utils::normalize_to_json_pointer(path)
913                            .replace("/properties/", "/")
914                            .trim_start_matches('#')
915                            .to_string();
916
917                        let current_data = self
918                            .eval_data
919                            .data()
920                            .pointer(&data_path)
921                            .unwrap_or(&Value::Null);
922
923                        if current_data != schema_value {
924                            changes.push((path.to_string(), schema_value.clone()));
925                        }
926                    }
927                }
928
929                // Recurse into properties
930                if let Some(Value::Object(props)) = map.get("properties") {
931                    for (key, val) in props {
932                        let next_path = if path == "#" {
933                            format!("#/properties/{}", key)
934                        } else {
935                            format!("{}/properties/{}", path, key)
936                        };
937                        self.collect_readonly_fixes(val, &next_path, changes);
938                    }
939                }
940            }
941            _ => {}
942        }
943    }
944
945    /// Check if a single field is hidden and needs clearing (Optimized non-recursive)
946    pub(crate) fn check_hidden_field(
947        &self,
948        schema_element: &Value,
949        path: &str,
950        hidden_fields: &mut Vec<String>,
951    ) {
952        match schema_element {
953            Value::Object(map) => {
954                // Check if field is hidden
955                let mut is_hidden = false;
956                if let Some(Value::Object(condition)) = map.get("condition") {
957                    if let Some(Value::Bool(h)) = condition.get("hidden") {
958                        is_hidden = *h;
959                    }
960                }
961
962                // Check keepHiddenValue config
963                let mut keep_hidden = false;
964                if let Some(Value::Object(config)) = map.get("config") {
965                    if let Some(Value::Object(all)) = config.get("all") {
966                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
967                            keep_hidden = *keep;
968                        }
969                    }
970                }
971
972                if is_hidden && !keep_hidden {
973                    let data_path = path_utils::normalize_to_json_pointer(path)
974                        .replace("/properties/", "/")
975                        .trim_start_matches('#')
976                        .to_string();
977
978                    let current_data = self
979                        .eval_data
980                        .data()
981                        .pointer(&data_path)
982                        .unwrap_or(&Value::Null);
983
984                    // If hidden and has non-empty value, add to list
985                    if current_data != &Value::Null && current_data != "" {
986                        hidden_fields.push(path.to_string());
987                    }
988                }
989            }
990            _ => {}
991        }
992    }
993
994    /// Recursively collect hidden fields that have values (candidates for clearing) (Legacy/Full-Scan)
995    #[allow(dead_code)]
996    pub(crate) fn collect_hidden_fields(
997        &self,
998        schema_element: &Value,
999        path: &str,
1000        hidden_fields: &mut Vec<String>,
1001    ) {
1002        match schema_element {
1003            Value::Object(map) => {
1004                // Check if field is hidden
1005                let mut is_hidden = false;
1006                if let Some(Value::Object(condition)) = map.get("condition") {
1007                    if let Some(Value::Bool(h)) = condition.get("hidden") {
1008                        is_hidden = *h;
1009                    }
1010                }
1011
1012                // Check keepHiddenValue config
1013                let mut keep_hidden = false;
1014                if let Some(Value::Object(config)) = map.get("config") {
1015                    if let Some(Value::Object(all)) = config.get("all") {
1016                        if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
1017                            keep_hidden = *keep;
1018                        }
1019                    }
1020                }
1021
1022                if is_hidden && !keep_hidden {
1023                    let data_path = path_utils::normalize_to_json_pointer(path)
1024                        .replace("/properties/", "/")
1025                        .trim_start_matches('#')
1026                        .to_string();
1027
1028                    let current_data = self
1029                        .eval_data
1030                        .data()
1031                        .pointer(&data_path)
1032                        .unwrap_or(&Value::Null);
1033
1034                    // If hidden and has non-empty value, add to list
1035                    if current_data != &Value::Null && current_data != "" {
1036                        hidden_fields.push(path.to_string());
1037                    }
1038                }
1039
1040                // Recurse into children
1041                for (key, val) in map {
1042                    if key == "properties" {
1043                        if let Value::Object(props) = val {
1044                            for (p_key, p_val) in props {
1045                                let next_path = if path == "#" {
1046                                    format!("#/properties/{}", p_key)
1047                                } else {
1048                                    format!("{}/properties/{}", path, p_key)
1049                                };
1050                                self.collect_hidden_fields(p_val, &next_path, hidden_fields);
1051                            }
1052                        }
1053                    } else if let Value::Object(_) = val {
1054                        // Skip known metadata keys and explicitly handled keys
1055                        if key == "condition"
1056                            || key == "config"
1057                            || key == "rules"
1058                            || key == "dependents"
1059                            || key == "hideLayout"
1060                            || key == "$layout"
1061                            || key == "$params"
1062                            || key == "definitions"
1063                            || key == "$defs"
1064                            || key.starts_with('$')
1065                        {
1066                            continue;
1067                        }
1068
1069                        let next_path = if path == "#" {
1070                            format!("#/{}", key)
1071                        } else {
1072                            format!("{}/{}", path, key)
1073                        };
1074                        self.collect_hidden_fields(val, &next_path, hidden_fields);
1075                    }
1076                }
1077            }
1078            _ => {}
1079        }
1080    }
1081
1082    /// Perform recursive hiding effect using reffed_by graph.
1083    /// Collects every data path that gets nulled into `invalidated_paths`.
1084    pub(crate) fn recursive_hide_effect(
1085        engine: &RLogic,
1086        evaluations: &IndexMap<String, LogicId>,
1087        reffed_by: &IndexMap<String, Vec<String>>,
1088        eval_data: &mut EvalData,
1089        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1090        mut hidden_fields: Vec<String>,
1091        queue: &mut Vec<(String, bool, Option<Vec<usize>>)>,
1092        result: &mut Vec<Value>,
1093    ) {
1094        while let Some(hf) = hidden_fields.pop() {
1095            let data_path = path_utils::normalize_to_json_pointer(&hf)
1096                .replace("/properties/", "/")
1097                .trim_start_matches('#')
1098                .to_string();
1099
1100            // clear data
1101            eval_data.set(&data_path, Value::Null);
1102            eval_cache.bump_data_version(&data_path);
1103
1104            // Create dependent object for result
1105            let mut change_obj = serde_json::Map::new();
1106            change_obj.insert(
1107                "$ref".to_string(),
1108                Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1109            );
1110            change_obj.insert("$hidden".to_string(), Value::Bool(true));
1111            change_obj.insert("clear".to_string(), Value::Bool(true));
1112            result.push(Value::Object(change_obj));
1113
1114            // Add to queue for standard dependent processing
1115            queue.push((hf.clone(), true, None));
1116
1117            // Check reffed_by to find other fields that might become hidden
1118            if let Some(referencing_fields) = reffed_by.get(&data_path) {
1119                for rb in referencing_fields {
1120                    // Evaluate condition.hidden for rb
1121                    // We need a way to run specific evaluation?
1122                    // We can check if rb has a hidden evaluation in self.evaluations
1123                    let hidden_eval_key = format!("{}/condition/hidden", rb);
1124
1125                    if let Some(logic_id) = evaluations.get(&hidden_eval_key) {
1126                        // Run evaluation
1127                        // Context: $value = current field (rb) value? No, $value usually refers to changed field in deps.
1128                        // But here we are just re-evaluating the rule.
1129                        // In JS logic: "const result = hiddenFn(runnerCtx);"
1130                        // runnerCtx has the updated data (we just set hf to null).
1131
1132                        let rb_data_path = path_utils::normalize_to_json_pointer(rb)
1133                            .replace("/properties/", "/")
1134                            .trim_start_matches('#')
1135                            .to_string();
1136                        let rb_value = eval_data
1137                            .data()
1138                            .pointer(&rb_data_path)
1139                            .cloned()
1140                            .unwrap_or(Value::Null);
1141
1142                        // We can use engine.run w/ eval_data
1143                        if let Ok(Value::Bool(is_hidden)) = engine.run(logic_id, eval_data.data()) {
1144                            if is_hidden {
1145                                // Check if rb is not already in hidden_fields and has value
1146                                // rb is &String, hidden_fields is Vec<String>
1147                                if !hidden_fields.contains(rb) {
1148                                    let has_value = rb_value != Value::Null && rb_value != "";
1149                                    if has_value {
1150                                        hidden_fields.push(rb.clone());
1151                                    }
1152                                }
1153                            }
1154                        }
1155                    }
1156                }
1157            }
1158        }
1159    }
1160
1161    /// Process the dependents queue.
1162    /// Collects every data path written into `eval_data` into `invalidated_paths`.
1163    pub(crate) fn process_dependents_queue(
1164        engine: &RLogic,
1165        evaluations: &IndexMap<String, LogicId>,
1166        eval_data: &mut EvalData,
1167        eval_cache: &mut crate::jsoneval::eval_cache::EvalCache,
1168        dependents_evaluations: &IndexMap<String, Vec<DependentItem>>,
1169        dep_formula_triggers: &IndexMap<String, Vec<(String, usize)>>,
1170        evaluated_schema: &Value,
1171        queue: &mut Vec<(String, bool, Option<Vec<usize>>)>,
1172        processed: &mut std::collections::HashMap<String, Option<std::collections::HashSet<usize>>>,
1173        result: &mut Vec<Value>,
1174        token: Option<&CancellationToken>,
1175        canceled_paths: Option<&mut Vec<String>>,
1176    ) -> Result<(), String> {
1177        while let Some((current_path, is_transitive, target_indices)) = queue.pop() {
1178            if let Some(t) = token {
1179                if t.is_cancelled() {
1180                    if let Some(cp) = canceled_paths {
1181                        cp.push(current_path.clone());
1182                        for (path, _, _) in queue.iter() {
1183                            cp.push(path.clone());
1184                        }
1185                    }
1186                    return Err("Cancelled".to_string());
1187                }
1188            }
1189
1190            let (should_run, indices_to_run) = match processed.get(&current_path) {
1191                Some(None) => {
1192                    // Already fully processed, skip
1193                    continue;
1194                }
1195                Some(Some(already_processed_indices)) => {
1196                    if let Some(targets) = &target_indices {
1197                        let new_targets: std::collections::HashSet<usize> = targets
1198                            .iter()
1199                            .copied()
1200                            .filter(|i| !already_processed_indices.contains(i))
1201                            .collect();
1202                        if new_targets.is_empty() {
1203                            continue;
1204                        }
1205                        (true, Some(new_targets))
1206                    } else {
1207                        (true, None)
1208                    }
1209                }
1210                None => (
1211                    true,
1212                    target_indices.clone().map(|t| t.into_iter().collect()),
1213                ),
1214            };
1215
1216            if !should_run {
1217                continue;
1218            }
1219
1220            let new_processed_state = if let Some(targets_to_run) = &indices_to_run {
1221                match processed.get(&current_path) {
1222                    Some(Some(existing_targets)) => {
1223                        let mut copy = existing_targets.clone();
1224                        for t in targets_to_run {
1225                            copy.insert(*t);
1226                        }
1227                        Some(copy)
1228                    }
1229                    _ => Some(targets_to_run.clone()),
1230                }
1231            } else {
1232                None
1233            };
1234            processed.insert(current_path.clone(), new_processed_state);
1235
1236            // Get the value of the changed field for $value context
1237            let current_data_path = path_utils::normalize_to_json_pointer(&current_path)
1238                .replace("/properties/", "/")
1239                .trim_start_matches('#')
1240                .to_string();
1241            let mut current_value = eval_data
1242                .data()
1243                .pointer(&current_data_path)
1244                .cloned()
1245                .unwrap_or(Value::Null);
1246
1247            // Re-enqueue source fields whose dependent formulas reference this changed field.
1248            // These are fields that have a dependent formula that checks `current_path` as a
1249            // contextual condition (e.g., ins_occ's formula for ph_occupation checks phins_relation).
1250            // When `current_path` changes, we need to re-evaluate those source fields' dependents.
1251            if let Some(formula_sources) = dep_formula_triggers.get(&current_data_path) {
1252                let mut targets_by_source: std::collections::HashMap<String, Vec<usize>> =
1253                    std::collections::HashMap::new();
1254                for (source_schema_path, dep_idx) in formula_sources {
1255                    let source_ptr = path_utils::dot_notation_to_schema_pointer(source_schema_path);
1256                    targets_by_source
1257                        .entry(source_ptr)
1258                        .or_default()
1259                        .push(*dep_idx);
1260                }
1261                for (source_ptr, targets) in targets_by_source {
1262                    // Check if it's already entirely processed
1263                    if let Some(None) = processed.get(&source_ptr) {
1264                        continue;
1265                    }
1266                    queue.push((source_ptr, true, Some(targets)));
1267                }
1268            }
1269
1270            // Find dependents for this path
1271            if let Some(dependent_items) = dependents_evaluations.get(&current_path) {
1272                for (dep_idx, dep_item) in dependent_items.iter().enumerate() {
1273                    if let Some(targets) = &indices_to_run {
1274                        if !targets.contains(&dep_idx) {
1275                            continue;
1276                        }
1277                    }
1278                    let ref_path = &dep_item.ref_path;
1279                    let pointer_path = path_utils::normalize_to_json_pointer(ref_path);
1280                    // Data paths don't include /properties/, strip it for data access
1281                    let data_path = pointer_path.replace("/properties/", "/");
1282
1283                    let current_ref_value = eval_data
1284                        .data()
1285                        .pointer(&data_path)
1286                        .cloned()
1287                        .unwrap_or(Value::Null);
1288
1289                    // Get field and parent field from schema
1290                    let field = evaluated_schema.pointer(&pointer_path).cloned();
1291
1292                    // Get parent field - skip /properties/ to get actual parent object
1293                    let parent_path = if let Some(last_slash) = pointer_path.rfind("/properties") {
1294                        &pointer_path[..last_slash]
1295                    } else {
1296                        "/"
1297                    };
1298                    let mut parent_field = if parent_path.is_empty() || parent_path == "/" {
1299                        evaluated_schema.clone()
1300                    } else {
1301                        evaluated_schema
1302                            .pointer(parent_path)
1303                            .cloned()
1304                            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
1305                    };
1306
1307                    // omit properties to minimize size of parent field
1308                    if let Value::Object(ref mut map) = parent_field {
1309                        map.remove("properties");
1310                        map.remove("$layout");
1311                    }
1312
1313                    let mut change_obj = serde_json::Map::new();
1314                    change_obj.insert(
1315                        "$ref".to_string(),
1316                        Value::String(path_utils::pointer_to_dot_notation(&data_path)),
1317                    );
1318                    if let Some(f) = field {
1319                        change_obj.insert("$field".to_string(), f);
1320                    }
1321                    change_obj.insert("$parentField".to_string(), parent_field);
1322                    change_obj.insert("transitive".to_string(), Value::Bool(is_transitive));
1323
1324                    // Skip writing back to a field that has already been processed.
1325                    // This prevents formula-triggered re-enqueues from creating circular writes:
1326                    // e.g., ins_gender → triggers phins_relation (via dep_formula_triggers) →
1327                    // phins_relation has a dep that writes back to ins_gender → we must not let that happen.
1328                    if processed.contains_key(ref_path) {
1329                        continue;
1330                    }
1331
1332                    let mut add_transitive = false;
1333                    let mut add_deps = false;
1334                    // Process clear
1335                    if let Some(clear_val) = &dep_item.clear {
1336                        let should_clear = Self::evaluate_dependent_value_static(
1337                            engine,
1338                            evaluations,
1339                            eval_data,
1340                            clear_val,
1341                            &current_value,
1342                            &current_ref_value,
1343                        )?;
1344                        let clear_bool = match should_clear {
1345                            Value::Bool(b) => b,
1346                            _ => false,
1347                        };
1348
1349                        if clear_bool {
1350                            if data_path == current_data_path {
1351                                current_value = Value::Null;
1352                            }
1353                            eval_data.set(&data_path, Value::Null);
1354                            eval_cache.bump_data_version(&data_path);
1355                            change_obj.insert("clear".to_string(), Value::Bool(true));
1356                            add_transitive = true;
1357                            add_deps = true;
1358                        }
1359                    }
1360
1361                    // Process value
1362                    if let Some(value_val) = &dep_item.value {
1363                        let computed_value = Self::evaluate_dependent_value_static(
1364                            engine,
1365                            evaluations,
1366                            eval_data,
1367                            value_val,
1368                            &current_value,
1369                            &current_ref_value,
1370                        )?;
1371                        let cleaned_val = clean_float_noise_scalar(computed_value);
1372
1373                        let is_clear =
1374                            cleaned_val == Value::Null || cleaned_val.as_str() == Some("");
1375
1376                        if cleaned_val != current_ref_value && !is_clear {
1377                            if data_path == current_data_path {
1378                                current_value = cleaned_val.clone();
1379                            }
1380                            eval_data.set(&data_path, cleaned_val.clone());
1381                            eval_cache.bump_data_version(&data_path);
1382                            change_obj.insert("value".to_string(), cleaned_val);
1383                            add_transitive = true;
1384                            add_deps = true;
1385                        }
1386                    }
1387
1388                    // add only when has clear / value
1389                    if add_deps {
1390                        result.push(Value::Object(change_obj));
1391                    }
1392
1393                    // Add this dependent to queue for transitive processing
1394                    if add_transitive {
1395                        queue.push((ref_path.clone(), true, None));
1396                    }
1397                }
1398            }
1399        }
1400        Ok(())
1401    }
1402}
1403
1404/// Extract the field key from a subform path.
1405///
1406/// Examples:
1407/// - `#/riders`                               → `riders`
1408/// - `#/properties/form/properties/riders`    → `riders`
1409/// - `#/items`                                → `items`
1410fn subform_field_key(subform_path: &str) -> String {
1411    // Strip leading `#/`
1412    let stripped = subform_path.trim_start_matches('#').trim_start_matches('/');
1413
1414    // The last non-"properties" segment is the field key
1415    stripped
1416        .split('/')
1417        .filter(|seg| !seg.is_empty() && *seg != "properties")
1418        .last()
1419        .unwrap_or(stripped)
1420        .to_string()
1421}