json_eval_rs/jsoneval/evaluate.rs
1use std::sync::Arc;
2
3use super::JSONEval;
4use crate::jsoneval::cancellation::CancellationToken;
5use crate::jsoneval::eval_data::EvalData;
6use crate::jsoneval::json_parser;
7use crate::jsoneval::path_utils;
8use crate::jsoneval::table_evaluate;
9use crate::time_block;
10use crate::utils::clean_float_noise_scalar;
11
12use serde_json::Value;
13
14/// Returns `true` if `new_item` (raw user input) is identity-compatible with `old_item`
15/// (snapshot that may contain computed formula outputs alongside raw input fields).
16///
17/// A full `==` comparison fails when `old_item` has extra keys written by formula evaluation
18/// (e.g., `wop_rider_premi`, `first_prem`) that are absent from the raw `new_item`. This helper
19/// compares only the fields present in `new_item`, ignoring extra keys in `old_item`:
20///
21/// - If both are objects: every key in `new` must match the same key in `old`.
22/// - Otherwise: standard equality (covers Null, scalar, array cases).
23///
24/// Used by `invalidate_subform_caches_on_structural_change` to detect genuine order/identity
25/// shifts without false positives from computed formula output fields in the snapshot.
26fn items_same_input_identity(old: Option<&Value>, new: Option<&Value>) -> bool {
27 match (old, new) {
28 (Some(Value::Object(old_map)), Some(Value::Object(new_map))) => new_map
29 .iter()
30 .all(|(k, new_val)| old_map.get(k).map_or(false, |old_val| old_val == new_val)),
31 (old, new) => old == new,
32 }
33}
34
35impl JSONEval {
36 /// Evaluate the schema with the given data and context.
37 ///
38 /// # Arguments
39 ///
40 /// * `data` - The data to evaluate.
41 /// * `context` - The context to evaluate.
42 ///
43 /// # Returns
44 ///
45 /// A `Result` indicating success or an error message.
46 pub fn evaluate(
47 &mut self,
48 data: &str,
49 context: Option<&str>,
50 paths: Option<&[String]>,
51 token: Option<&CancellationToken>,
52 ) -> Result<(), String> {
53 if let Some(t) = token {
54 if t.is_cancelled() {
55 return Err("Cancelled".to_string());
56 }
57 }
58 time_block!("evaluate() [total]", {
59 // Use SIMD-accelerated JSON parsing
60 // Parse and update data/context
61 let data_value = time_block!(" parse data", { json_parser::parse_json_str(data)? });
62 let context_value = time_block!(" parse context", {
63 if let Some(ctx) = context {
64 json_parser::parse_json_str(ctx)?
65 } else {
66 Value::Object(serde_json::Map::new())
67 }
68 });
69 self.evaluate_internal_with_new_data(data_value, context_value, paths, token)
70 })
71 }
72
73 /// Internal helper to evaluate with all data/context provided as Values.
74 /// `pub(crate)` so the cache-swap path in `evaluate_subform` can call it directly
75 /// after swapping the parent cache in, bypassing the string-parsing overhead.
76 pub(crate) fn evaluate_internal_with_new_data(
77 &mut self,
78 data: Value,
79 context: Value,
80 paths: Option<&[String]>,
81 token: Option<&CancellationToken>,
82 ) -> Result<(), String> {
83 time_block!(" evaluate_internal_with_new_data", {
84 // Reuse the previously stored snapshot as `old_data` to avoid an O(n) deep clone
85 // on every main-form evaluation call.
86 let has_previous_eval = self.eval_cache.main_form_snapshot.is_some();
87 let old_data = self
88 .eval_cache
89 .main_form_snapshot
90 .take()
91 .unwrap_or_else(|| self.eval_data.snapshot_data_clone());
92
93 let old_context = self
94 .eval_data
95 .data()
96 .get("$context")
97 .cloned()
98 .unwrap_or(Value::Null);
99
100 // Store data, context and replace in eval_data (clone once instead of twice)
101 self.data = data.clone();
102 self.context = context.clone();
103 time_block!(" replace_data_and_context", {
104 self.eval_data.replace_data_and_context(data, context);
105 });
106
107 let new_data = self.eval_data.snapshot_data_clone();
108 let new_context = self
109 .eval_data
110 .data()
111 .get("$context")
112 .cloned()
113 .unwrap_or(Value::Null);
114
115 if has_previous_eval
116 && old_data == new_data
117 && old_context == new_context
118 && paths.is_none()
119 {
120 // Perfect cache hit for unmodified payload: fully skip tree traversal.
121 // Restore snapshot since nothing changed.
122 self.eval_cache.main_form_snapshot = Some(new_data);
123 return Ok(());
124 }
125
126 // Proactively populate per-item caches for all existing subform items from the loaded data.
127 // When a user opens an existing form (e.g. reload from DB), the main `evaluate(data)`
128 // establishes the baseline state. If we don't populate subform caches here, the first
129 // time the user opens a rider (`evaluate_subform`), the cache is empty (item_snapshot=Null).
130 // The diff between Null and the full rider data will then mark EVERY field (sa, code, etc.)
131 // as "changed", spuriously bumping secondary trackers and causing false T2 table misses.
132 for (subform_path, subform) in &mut self.subforms {
133 let subform_ptr =
134 crate::jsoneval::path_utils::normalize_to_json_pointer(subform_path);
135 if let Some(items) = new_data.pointer(&subform_ptr).and_then(|v| v.as_array()) {
136 for (idx, item_val) in items.iter().enumerate() {
137 self.eval_cache.ensure_active_item_cache(idx);
138 if let Some(c) = self.eval_cache.subform_caches.get_mut(&idx) {
139 c.item_snapshot = item_val.clone();
140 }
141 subform.eval_cache.ensure_active_item_cache(idx);
142 if let Some(c) = subform.eval_cache.subform_caches.get_mut(&idx) {
143 c.item_snapshot = item_val.clone();
144 }
145 }
146 }
147 }
148
149 self.eval_cache
150 .store_snapshot_and_diff_versions(&old_data, &new_data);
151 // Save snapshot for the next evaluation cycle (avoids one snapshot_data_clone() call)
152 self.eval_cache.main_form_snapshot = Some(new_data.clone());
153
154 // Detect subform array structural changes: length differences OR item identity shifts
155 // (e.g., rider reorder). When items move indices their per-index T1 caches are misaligned,
156 // and T2 global entries keyed on subform-local dep paths (e.g., `/riders/code`) must be
157 // evicted — the parent diff only bumps indexed full paths like
158 // `/illustration/product_benefit/riders/2/code`, which never match the stored dep key.
159 self.invalidate_subform_caches_on_structural_change(&old_data, &new_data);
160
161 // Generation-based fast skip: diff_and_update_versions bumps data_versions.versions
162 // but does NOT increment eval_generation. Only bump_data_version / bump_params_version
163 // (called from formula stores) advance eval_generation.
164 // If eval_generation == last_evaluated_generation after the diff, no formula's cached
165 // deps are actually stale — all batches would be cache hits. Skip the full traversal.
166 // Safe only in the external evaluate() path; run_re_evaluate_pass must always evaluate.
167 if paths.is_none() && !self.eval_cache.needs_full_evaluation() {
168 self.evaluate_others(paths, token, false);
169 return Ok(());
170 }
171
172 // Call internal evaluate (uses existing data if not provided)
173 self.evaluate_internal(paths, token)
174 })
175 }
176
177 /// Detect structural changes in subform arrays between `old_data` and `new_data`
178 /// and evict stale caches accordingly.
179 pub(crate) fn invalidate_subform_caches_on_structural_change(
180 &mut self,
181 old_data: &Value,
182 new_data: &Value,
183 ) {
184 use crate::jsoneval::path_utils::normalize_to_json_pointer;
185
186 for (subform_path, _) in &self.subforms {
187 // Resolve the data pointer for this subform
188 // (e.g., `/illustration/product_benefit/riders`)
189 let subform_ptr = normalize_to_json_pointer(subform_path).replace("/properties/", "/");
190
191 let old_items = old_data.pointer(&subform_ptr).and_then(Value::as_array);
192 let new_items = new_data.pointer(&subform_ptr).and_then(Value::as_array);
193
194 let old_len = old_items.map(Vec::len).unwrap_or(0);
195 let new_len = new_items.map(Vec::len).unwrap_or(0);
196 let min_len = old_len.min(new_len);
197
198 // Detect identity shift in the overlapping index range using subset comparison.
199 // We check whether the raw input fields of new_items[i] all match old_items[i],
200 // ignoring extra computed keys that only exist in the old snapshot.
201 let identities_shifted = (0..min_len).any(|i| {
202 let old_item = old_items.and_then(|a| a.get(i));
203 let new_item = new_items.and_then(|a| a.get(i));
204 !items_same_input_identity(old_item, new_item)
205 });
206
207 if old_len == new_len && !identities_shifted {
208 continue; // No structural change for this subform
209 }
210
211 // Build the subform-local dep-path prefix stored in T2 dep_versions
212 // (e.g., `/riders/` for a riders subform). T2 dep keys are normalized data
213 // paths — never schema paths — so only one prefix is needed.
214 let field_key = subform_ptr
215 .split('/')
216 .next_back()
217 .unwrap_or(subform_ptr.as_str());
218 let subform_dep_prefix = format!("/{}/", field_key);
219
220 // Evict T2 global entries whose deps include any subform-local path.
221 // `retain` evicts inline (no intermediate Vec allocation).
222 // Collect the normalized path of each evicted key for the params_versions bump.
223 let mut evicted_paths: Vec<String> = Vec::new();
224 self.eval_cache.entries.retain(|eval_key, entry| {
225 let has_subform_dep = entry
226 .dep_versions
227 .keys()
228 .any(|dep| dep.starts_with(&subform_dep_prefix));
229
230 if has_subform_dep {
231 // Normalize eval_key → params data path once, at eviction time
232 let raw = normalize_to_json_pointer(eval_key).replace("/properties/", "/");
233 let normalized = raw.trim_start_matches('#');
234 evicted_paths.push(if normalized.starts_with('/') {
235 normalized.to_string()
236 } else {
237 format!("/{}", normalized)
238 });
239 false // remove entry
240 } else {
241 true // keep
242 }
243 });
244
245 // Bump params_versions for every evicted T2 entry so downstream $params formulas
246 // (SA_WOP_RIDER, TOTAL_WOP_SA, etc.) correctly miss their caches.
247 for path in &evicted_paths {
248 self.eval_cache.params_versions.bump(path);
249 }
250
251 // Clear T1 per-item caches for indices where item identity has shifted.
252 // This prevents stale per-rider results being reused for a different rider
253 // occupying the same array slot after a reorder.
254 for idx in 0..min_len {
255 let old_item = old_items.and_then(|a| a.get(idx));
256 let new_item = new_items.and_then(|a| a.get(idx));
257 if !items_same_input_identity(old_item, new_item) {
258 if let Some(c) = self.eval_cache.subform_caches.get_mut(&idx) {
259 c.entries.clear();
260 c.data_versions = crate::jsoneval::eval_cache::VersionTracker::new();
261 }
262 }
263 }
264 // Prune T1 caches for indices that no longer exist (removed items)
265 self.eval_cache.prune_subform_caches(new_len);
266
267 if !evicted_paths.is_empty() || old_len != new_len {
268 self.eval_cache.eval_generation += 1;
269 }
270 }
271 }
272
273 /// Fast variant of `evaluate_internal_with_new_data` for the cache-swap path.
274 ///
275 /// The caller (e.g. `run_subform_pass` / `evaluate_subform_item`) has **already**:
276 /// 1. Called `replace_data_and_context` on `subform.eval_data` with the merged payload.
277 /// 2. Computed the item-level diff and bumped `subform_caches[idx].data_versions` accordingly.
278 /// 3. Swapped the parent cache into `subform.eval_cache` so Tier 2 entries are visible.
279 /// 4. Set `active_item_index = Some(idx)` on the swapped-in cache.
280 ///
281 /// Skipping the expensive `snapshot_data_clone()` × 2 and `diff_and_update_versions`
282 /// saves ~40–80ms per rider on a 5 MB parent payload.
283 pub(crate) fn evaluate_internal_pre_diffed(
284 &mut self,
285 paths: Option<&[String]>,
286 token: Option<&CancellationToken>,
287 ) -> Result<(), String> {
288 debug_assert!(
289 self.eval_cache.active_item_index.is_some(),
290 "evaluate_internal_pre_diffed called without active_item_index — \
291 caller must set up the cache-swap before calling this method"
292 );
293
294 // Always delegate to evaluate_internal so that evaluated_schema is populated correctly
295 // for every item. The previous generation-based skip here left evaluated_schema stale
296 // (with the prior rider's values) when no deps changed — causing get_evaluated_schema_subform
297 // to return wrong values for all but the last-evaluated rider.
298 //
299 // evaluate_internal's all-hit fast path (lines ~314–338) handles the no-change case
300 // efficiently: it writes eval_data + evaluated_schema per formula from T1 cache and
301 // skips the expensive formula engine entirely.
302 self.evaluate_internal(paths, token)
303 }
304
305 /// Internal evaluate that can be called when data is already set
306 /// This avoids double-locking and unnecessary data cloning for re-evaluation from evaluate_dependents
307 pub(crate) fn evaluate_internal(
308 &mut self,
309 paths: Option<&[String]>,
310 token: Option<&CancellationToken>,
311 ) -> Result<(), String> {
312 if let Some(t) = token {
313 if t.is_cancelled() {
314 return Err("Cancelled".to_string());
315 }
316 }
317 time_block!(" evaluate_internal() [total]", {
318 // Acquire lock for synchronous execution
319 let _lock = self.eval_lock.lock().unwrap();
320
321 // Normalize paths to schema pointers for correct filtering
322 let normalized_paths_storage; // Keep alive
323 let normalized_paths = if let Some(p_list) = paths {
324 normalized_paths_storage = p_list
325 .iter()
326 .flat_map(|p| {
327 let normalized = if p.starts_with("#/") {
328 p.to_string()
329 } else if p.starts_with('/') {
330 format!("#{}", p)
331 } else {
332 format!("#/{}", p.replace('.', "/"))
333 };
334 vec![normalized]
335 })
336 .collect::<Vec<_>>();
337 Some(normalized_paths_storage.as_slice())
338 } else {
339 None
340 };
341
342 // Borrow sorted_evaluations via Arc (avoid deep-cloning Vec<Vec<String>>)
343 let eval_batches = self.sorted_evaluations.clone();
344
345 // Track whether any entry was a cache miss (required an actual formula run).
346 // When false (all hits), evaluate_others can skip resolve_layout because no
347 // values changed and the layout state is guaranteed identical.
348 // On the very first evaluation (last_evaluated_generation == u64::MAX), we MUST
349 // force a cache miss so that static schemas (with no formulas) still process
350 // URL templates and layout resolution once.
351 let mut had_cache_miss = self.eval_cache.last_evaluated_generation == u64::MAX;
352
353 // Process each batch - sequentially
354 // Batches are processed sequentially to maintain dependency order
355 // Process value evaluations (simple computed fields with no dependencies)
356 let eval_data_values = self.eval_data.clone();
357 time_block!(" evaluate values", {
358 for eval_key in self.value_evaluations.iter() {
359 if let Some(t) = token {
360 if t.is_cancelled() {
361 return Err("Cancelled".to_string());
362 }
363 }
364 // Skip if has dependencies (handled in sorted batches with correct ordering)
365 if let Some(deps) = self.dependencies.get(eval_key) {
366 if !deps.is_empty() {
367 continue;
368 }
369 }
370
371 // Filter items if paths are provided
372 if let Some(filter_paths) = normalized_paths {
373 if !filter_paths.is_empty()
374 && !filter_paths.iter().any(|p| {
375 eval_key.starts_with(p.as_str()) || p.starts_with(eval_key.as_str())
376 })
377 {
378 continue;
379 }
380 }
381
382 let pointer_path = path_utils::normalize_to_json_pointer(eval_key).into_owned();
383 let empty_deps = indexmap::IndexSet::new();
384 let deps = self.dependencies.get(eval_key).unwrap_or(&empty_deps);
385
386 // Cache hit check
387 if let Some(_cached_result) = self.eval_cache.check_cache(eval_key, deps) {
388 continue;
389 }
390
391 had_cache_miss = true;
392 // Cache miss - evaluate
393 if let Some(logic_id) = self.evaluations.get(eval_key) {
394 match self.engine.run(logic_id, eval_data_values.data()) {
395 Ok(val) => {
396 let cleaned_val = clean_float_noise_scalar(val);
397 self.eval_cache
398 .store_cache(eval_key, deps, cleaned_val.clone());
399
400 if let Some(pointer_value) =
401 self.evaluated_schema.pointer_mut(&pointer_path)
402 {
403 *pointer_value = cleaned_val;
404 }
405 }
406 Err(_) => {
407 // Formula failed — ensure no raw $evaluation object leaks.
408 // Write null only if the node still holds the unevaluated formula.
409 if let Some(node) =
410 self.evaluated_schema.pointer_mut(&pointer_path)
411 {
412 if node.is_object()
413 && node.get("$evaluation").is_some()
414 {
415 *node = Value::Null;
416 }
417 }
418 }
419 }
420 }
421 }
422 });
423
424 time_block!(" process batches", {
425 for batch in eval_batches.iter() {
426 if let Some(t) = token {
427 if t.is_cancelled() {
428 return Err("Cancelled".to_string());
429 }
430 }
431 // Skip empty batches
432 if batch.is_empty() {
433 continue;
434 }
435
436 // Check if we can skip this entire batch optimization
437 let batch_skipped = time_block!(" batch filter check", {
438 if let Some(filter_paths) = normalized_paths {
439 if !filter_paths.is_empty() {
440 let batch_has_match = batch.iter().any(|eval_key| {
441 filter_paths.iter().any(|p| {
442 eval_key.starts_with(p.as_str())
443 || (p.starts_with(eval_key.as_str())
444 && !eval_key.contains("/$params/"))
445 })
446 });
447 !batch_has_match
448 } else {
449 false
450 }
451 } else {
452 false
453 }
454 });
455 if batch_skipped {
456 continue;
457 }
458
459 // Fast path: try to resolve every eval_key in this batch from cache.
460 // If all hit, skip the expensive exclusive_clone() of the full eval_data tree.
461 // This is critical for subforms where eval_data contains the full parent payload.
462 let all_cache_hit = time_block!(" batch cache fast path", {
463 let mut batch_hits: Vec<(String, Value)> = Vec::with_capacity(batch.len());
464 let all_hit = batch.iter().all(|eval_key| {
465 let empty_deps = indexmap::IndexSet::new();
466 let deps = self.dependencies.get(eval_key).unwrap_or(&empty_deps);
467 if let Some(cached) = self.eval_cache.check_cache(eval_key, deps) {
468 let pointer_path =
469 path_utils::normalize_to_json_pointer(eval_key).into_owned();
470 batch_hits.push((pointer_path, cached));
471 true
472 } else {
473 false
474 }
475 });
476
477 if all_hit {
478 // Populate eval_data AND evaluated_schema so both downstream batches
479 // and get_evaluated_schema callers see the correct per-item values.
480 // Previously only eval_data was written here, leaving evaluated_schema
481 // with stale values from the last full-miss evaluation (e.g. the first
482 // rider), causing all riders to report the same schema outputs.
483 for (ptr, val) in batch_hits {
484 self.eval_data.set(&ptr, val.clone());
485 if let Some(schema_value) = self.evaluated_schema.pointer_mut(&ptr)
486 {
487 *schema_value = val;
488 }
489 }
490 }
491 // Partial or full miss — fall through to the normal exclusive_clone path below.
492 // batch_hits is dropped here; cache lookups will repeat but that's cheap.
493 all_hit
494 });
495 if all_cache_hit {
496 continue;
497 }
498 had_cache_miss = true;
499
500 // Sequential execution.
501 // For each formula miss, snapshot_data() gives an O(1) Arc::clone
502 // as a stable read view. The Arc is dropped before self.eval_data.set()
503 // so Arc::make_mut always finds rc=1 — zero deep copy, zero latency.
504 time_block!(" batch sequential eval", {
505 for eval_key in batch {
506 if let Some(t) = token {
507 if t.is_cancelled() {
508 return Err("Cancelled".to_string());
509 }
510 }
511 // Filter individual items if paths are provided
512 if let Some(filter_paths) = normalized_paths {
513 if !filter_paths.is_empty()
514 && !filter_paths.iter().any(|p| {
515 eval_key.starts_with(p.as_str())
516 || (p.starts_with(eval_key.as_str())
517 && !eval_key.contains("/$params/"))
518 })
519 {
520 continue;
521 }
522 }
523
524 let pointer_path =
525 path_utils::normalize_to_json_pointer(eval_key).into_owned();
526
527 // Cache miss - evaluate
528 let is_table = self.table_metadata.contains_key(eval_key);
529
530 if is_table {
531 time_block!(" table eval", {
532 // Snapshot for table read access: Arc::clone is O(1).
533 // Scoped so it's dropped before self.eval_data.set() below,
534 // keeping self.eval_data.data at rc=1 so Arc::make_mut is free.
535 let table_result = {
536 let table_scope =
537 EvalData::from_arc(self.eval_data.snapshot_data());
538 table_evaluate::evaluate_table(
539 self,
540 eval_key,
541 &table_scope,
542 token,
543 )
544 // table_scope dropped here → rc back to 1
545 };
546 if let Ok((rows, external_deps_opt)) = table_result {
547 let result_val = Value::Array(rows);
548 if let Some(external_deps) = external_deps_opt {
549 self.eval_cache.store_cache(
550 eval_key,
551 &external_deps,
552 result_val.clone(),
553 );
554 }
555
556 // NOTE: bump_params_version / bump_data_version for table results
557 // is now handled inside store_cache (conditional on value change).
558 // The separate bump here was double-counting: store_cache uses T2
559 // comparison while this block used eval_data as reference point,
560 // causing two version increments per changed table.
561
562 let static_key = format!("/$table{}", pointer_path);
563 let arc_value = std::sync::Arc::new(result_val);
564
565 Arc::make_mut(&mut self.static_arrays).insert(
566 static_key.clone(),
567 std::sync::Arc::clone(&arc_value),
568 );
569
570 self.eval_data.set(&pointer_path, Value::clone(&arc_value));
571
572 let marker =
573 serde_json::json!({ "$static_array": static_key });
574 if let Some(schema_value) =
575 self.evaluated_schema.pointer_mut(&pointer_path)
576 {
577 *schema_value = marker;
578 }
579 }
580 });
581 } else {
582 let empty_deps = indexmap::IndexSet::new();
583 let deps = self.dependencies.get(eval_key).unwrap_or(&empty_deps);
584 let cached_result = self.eval_cache.check_cache(eval_key, &deps);
585
586 time_block!(" formula eval", {
587 if let Some(cached_result) = cached_result {
588 // Must still populate eval_data out of cache so subsequent formulas
589 // referencing this path in the same iteration can read the exact value
590 self.eval_data.set(&pointer_path, cached_result.clone());
591 if let Some(schema_value) =
592 self.evaluated_schema.pointer_mut(&pointer_path)
593 {
594 *schema_value = cached_result;
595 }
596 } else if let Some(logic_id) = self.evaluations.get(eval_key) {
597 // snapshot_data() is O(1) Arc::clone — no deep copy.
598 // Arc is moved into `snap` and lives only for the
599 // engine.run() call, then dropped before set() below.
600 // This keeps self.eval_data.data at rc=1 when set()
601 // calls Arc::make_mut, so no deep clone ever occurs.
602 let val = {
603 let snap = self.eval_data.snapshot_data();
604 self.engine.run(logic_id, &*snap)
605 // snap dropped here → rc back to 1
606 };
607 match val {
608 Ok(val) => {
609 let cleaned_val = clean_float_noise_scalar(val);
610 let data_path =
611 pointer_path.replace("/properties/", "/");
612 self.eval_cache.store_cache(
613 eval_key,
614 &deps,
615 cleaned_val.clone(),
616 );
617
618 // Bump data_versions when non-$params field value changes.
619 // $params bumps are handled inside store_cache (conditional).
620 let old_val = self
621 .eval_data
622 .get(&data_path)
623 .cloned()
624 .unwrap_or(Value::Null);
625 if cleaned_val != old_val
626 && !data_path.starts_with("/$params")
627 {
628 self.eval_cache.bump_data_version(&data_path);
629 }
630
631 self.eval_data.set(&pointer_path, cleaned_val.clone());
632 if let Some(schema_value) =
633 self.evaluated_schema.pointer_mut(&pointer_path)
634 {
635 *schema_value = cleaned_val;
636 }
637 }
638 Err(_) => {
639 // Formula failed — ensure no raw $evaluation object leaks.
640 // Write null only if the node still holds the unevaluated formula.
641 if let Some(node) =
642 self.evaluated_schema.pointer_mut(&pointer_path)
643 {
644 if node.is_object()
645 && node.get("$evaluation").is_some()
646 {
647 *node = Value::Null;
648 }
649 }
650 }
651 }
652 }
653 });
654 }
655 }
656 });
657 }
658 });
659
660 // Drop lock before calling evaluate_others
661 drop(_lock);
662
663 // Mark generation stable so the next evaluate_internal call can detect whether
664 // any formula was actually re-stored (via bump_data/params_version) since this run.
665 self.eval_cache.mark_evaluated();
666
667 self.evaluate_others(paths, token, had_cache_miss);
668
669 Ok(())
670 })
671 }
672
673 pub(crate) fn evaluate_others(
674 &mut self,
675 paths: Option<&[String]>,
676 token: Option<&CancellationToken>,
677 had_cache_miss: bool,
678 ) {
679 if let Some(t) = token {
680 if t.is_cancelled() {
681 return;
682 }
683 }
684 time_block!(" evaluate_others()", {
685 // Step 1: Evaluate "rules" and "others" categories with caching
686 // Rules are evaluated here so their values are available in evaluated_schema
687 let combined_count = self.rules_evaluations.len() + self.others_evaluations.len();
688 if combined_count > 0 {
689 time_block!(" evaluate rules+others", {
690 let eval_data_snapshot = self.eval_data.clone();
691
692 let normalized_paths: Option<Vec<String>> = paths.map(|p_list| {
693 p_list
694 .iter()
695 .flat_map(|p| {
696 let ptr = path_utils::dot_notation_to_schema_pointer(p);
697 // Also support version with /properties/ prefix for root match
698 let with_props = if ptr.starts_with("#/") {
699 format!("#/properties/{}", &ptr[2..])
700 } else {
701 ptr.clone()
702 };
703 vec![ptr, with_props]
704 })
705 .collect()
706 });
707
708 // Sequential evaluation
709 let combined_evals: Vec<&String> = self
710 .rules_evaluations
711 .iter()
712 .chain(self.others_evaluations.iter())
713 .collect();
714
715 for eval_key in combined_evals {
716 if let Some(t) = token {
717 if t.is_cancelled() {
718 return;
719 }
720 }
721 // Filter items if paths are provided
722 if let Some(filter_paths) = normalized_paths.as_ref() {
723 if !filter_paths.is_empty()
724 && !filter_paths.iter().any(|p| {
725 eval_key.starts_with(p.as_str())
726 || (p.starts_with(eval_key.as_str())
727 && !eval_key.contains("/$params/"))
728 })
729 {
730 continue;
731 }
732 }
733
734 let pointer_path =
735 path_utils::normalize_to_json_pointer(eval_key).into_owned();
736 let empty_deps = indexmap::IndexSet::new();
737 let deps = self.dependencies.get(eval_key).unwrap_or(&empty_deps);
738
739 if let Some(cached_result) = self.eval_cache.check_cache(eval_key, &deps) {
740 if let Some(pointer_value) =
741 self.evaluated_schema.pointer_mut(&pointer_path)
742 {
743 if !pointer_path.starts_with("$")
744 && pointer_path.contains("/rules/")
745 && !pointer_path.ends_with("/value")
746 {
747 if let Some(pointer_obj) = pointer_value.as_object_mut() {
748 pointer_obj.remove("$evaluation");
749 pointer_obj
750 .insert("value".to_string(), cached_result.clone());
751 }
752 } else {
753 *pointer_value = cached_result.clone();
754 }
755 }
756 continue;
757 }
758 if let Some(logic_id) = self.evaluations.get(eval_key) {
759 match self.engine.run(logic_id, eval_data_snapshot.data()) {
760 Ok(val) => {
761 let cleaned_val = clean_float_noise_scalar(val);
762 self.eval_cache
763 .store_cache(eval_key, &deps, cleaned_val.clone());
764
765 if let Some(pointer_value) =
766 self.evaluated_schema.pointer_mut(&pointer_path)
767 {
768 if !pointer_path.starts_with("$")
769 && pointer_path.contains("/rules/")
770 && !pointer_path.ends_with("/value")
771 {
772 match pointer_value.as_object_mut() {
773 Some(pointer_obj) => {
774 pointer_obj.remove("$evaluation");
775 pointer_obj
776 .insert("value".to_string(), cleaned_val);
777 }
778 None => continue,
779 }
780 } else {
781 *pointer_value = cleaned_val;
782 }
783 }
784 }
785 Err(_) => {
786 // Formula failed — ensure no raw $evaluation object leaks.
787 // Write null only if the node still holds the unevaluated formula.
788 if let Some(node) =
789 self.evaluated_schema.pointer_mut(&pointer_path)
790 {
791 if node.is_object() && node.get("$evaluation").is_some() {
792 *node = Value::Null;
793 }
794 }
795 }
796 }
797 }
798 }
799 });
800 }
801 });
802
803 // Step 2: Evaluate options URL templates (handles {variable} patterns)
804 // Skip when all entries were cache hits — template inputs can't have changed.
805 if had_cache_miss {
806 time_block!(" evaluate_options_templates", {
807 self.evaluate_options_templates(paths);
808 });
809
810 // Step 3: Resolve layout logic (metadata injection, hidden propagation)
811 // Skip when no values changed — layout state is guaranteed identical.
812 time_block!(" resolve_layout", {
813 let _ = self.resolve_layout(false);
814 });
815 }
816 }
817
818 /// Evaluate options URL templates (handles {variable} patterns)
819 fn evaluate_options_templates(&mut self, paths: Option<&[String]>) {
820 // Use pre-collected options templates from parsing (Arc clone is cheap)
821 let templates_to_eval = self.options_templates.clone();
822
823 // Evaluate each template
824 for (path, template_str, params_path) in templates_to_eval.iter() {
825 // Filter items if paths are provided
826 // 'path' here is the schema path to the field (dot notation or similar, need to check)
827 // It seems to be schema pointer based on usage in other methods
828 if let Some(filter_paths) = paths {
829 if !filter_paths.is_empty()
830 && !filter_paths
831 .iter()
832 .any(|p| path.starts_with(p.as_str()) || p.starts_with(path.as_str()))
833 {
834 continue;
835 }
836 }
837
838 if let Some(params) = self.evaluated_schema.pointer(¶ms_path) {
839 if let Ok(evaluated) = self.evaluate_template(&template_str, params) {
840 if let Some(target) = self.evaluated_schema.pointer_mut(&path) {
841 *target = Value::String(evaluated);
842 }
843 }
844 }
845 }
846 }
847
848 /// Evaluate a template string like "api/users/{id}" with params
849 fn evaluate_template(&self, template: &str, params: &Value) -> Result<String, String> {
850 let mut result = template.to_string();
851
852 // Simple template evaluation: replace {key} with params.key
853 if let Value::Object(params_map) = params {
854 for (key, value) in params_map {
855 let placeholder = format!("{{{}}}", key);
856 if let Some(str_val) = value.as_str() {
857 result = result.replace(&placeholder, str_val);
858 } else {
859 // Convert non-string values to strings
860 result = result.replace(&placeholder, &value.to_string());
861 }
862 }
863 }
864
865 Ok(result)
866 }
867}