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