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