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