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