1use std::collections::HashMap;
8use std::sync::Arc;
9
10use parking_lot::Mutex;
11
12use serde_json::Value;
13
14use crate::error::ConfigError;
15use crate::state::State;
16
17use super::contract::ComputedContract;
18
19pub type ComputeFn = Arc<dyn Fn(&State) -> Option<Value> + Send + Sync>;
21
22pub struct ComputedVar {
29 pub key: String,
31 pub dependencies: Vec<String>,
33 pub compute: ComputeFn,
35}
36
37pub struct ComputedRegistry {
43 vars: Vec<ComputedVar>,
45 dep_index: HashMap<String, Vec<usize>>,
48 cursor: Mutex<Option<u64>>,
51}
52
53impl Default for ComputedRegistry {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl ComputedRegistry {
60 pub fn new() -> Self {
62 Self {
63 vars: Vec::new(),
64 dep_index: HashMap::new(),
65 cursor: Mutex::new(None),
66 }
67 }
68
69 pub fn register(&mut self, var: ComputedVar) -> Result<(), ConfigError> {
76 let previous = match self.vars.iter().position(|v| v.key == var.key) {
77 Some(pos) => Some((pos, std::mem::replace(&mut self.vars[pos], var))),
78 None => {
79 self.vars.push(var);
80 None
81 }
82 };
83 if let Err(err) = self.topo_sort() {
84 match previous {
86 Some((pos, old)) => self.vars[pos] = old,
87 None => {
88 self.vars.pop();
89 }
90 }
91 return Err(err);
92 }
93 self.rebuild_dep_index();
94 *self.cursor.get_mut() = None;
95 Ok(())
96 }
97
98 pub fn recompute_changed(&self, state: &State) -> Vec<String> {
106 let mut cursor = self.cursor.lock();
107 let changed = match cursor.and_then(|at| state.try_mutations_since(at)) {
108 Some(mutations) => {
109 let mut keys: Vec<String> = Vec::new();
110 for mutation in mutations {
111 if let Some(var) = mutation.key.strip_prefix("derived:") {
112 keys.push(var.to_string());
113 }
114 keys.push(mutation.key);
115 }
116 keys.sort_unstable();
117 keys.dedup();
118 self.recompute_indices(state, &keys, true)
119 }
120 None => self.recompute(state),
121 };
122 *cursor = Some(state.mutation_cursor());
125 changed
126 }
127
128 pub fn recompute(&self, state: &State) -> Vec<String> {
131 let mut changed = Vec::new();
132 for var in &self.vars {
133 if Self::evaluate(var, state) {
134 changed.push(var.key.clone());
135 }
136 }
137 changed
138 }
139
140 pub fn recompute_affected(&self, state: &State, changed_keys: &[String]) -> Vec<String> {
146 self.recompute_indices(state, changed_keys, false)
147 }
148
149 fn recompute_indices(
153 &self,
154 state: &State,
155 changed_keys: &[String],
156 include_undeclared: bool,
157 ) -> Vec<String> {
158 let mut visited = vec![false; self.vars.len()];
160 let mut affected_set = Vec::new();
161 if include_undeclared {
162 for (idx, var) in self.vars.iter().enumerate() {
163 if var.dependencies.is_empty() {
164 visited[idx] = true;
165 affected_set.push(idx);
166 }
167 }
168 }
169
170 let mut work_keys: Vec<String> = changed_keys.to_vec();
172 work_keys.extend(affected_set.iter().map(|&idx| self.vars[idx].key.clone()));
173
174 while let Some(key) = work_keys.pop() {
175 if let Some(indices) = self.dep_index.get(&key) {
177 for &idx in indices {
178 if !visited[idx] {
179 visited[idx] = true;
180 affected_set.push(idx);
181 work_keys.push(self.vars[idx].key.clone());
184 }
185 }
186 }
187 }
188
189 affected_set.sort_unstable();
191
192 let mut changed = Vec::new();
193 for idx in affected_set {
194 let var = &self.vars[idx];
195 if Self::evaluate(var, state) {
196 changed.push(var.key.clone());
197 }
198 }
199 changed
200 }
201
202 fn evaluate(var: &ComputedVar, state: &State) -> bool {
205 let Some(new_val) = (var.compute)(state) else {
206 return false;
207 };
208 let derived_key = format!("derived:{}", var.key);
209 if state.get_raw(&derived_key).as_ref() == Some(&new_val) {
210 return false;
211 }
212 let _ = state.set(&derived_key, new_val);
213 true
214 }
215
216 pub fn validate(&self) -> Result<(), ConfigError> {
219 let n = self.vars.len();
221 if n == 0 {
222 return Ok(());
223 }
224
225 let key_to_idx: HashMap<&str, usize> = self
226 .vars
227 .iter()
228 .enumerate()
229 .map(|(i, v)| (v.key.as_str(), i))
230 .collect();
231
232 let mut in_degree = vec![0usize; n];
233 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
234
235 for (i, var) in self.vars.iter().enumerate() {
236 for dep in &var.dependencies {
237 if let Some(&dep_idx) = key_to_idx.get(dep.as_str()) {
238 adj[dep_idx].push(i);
239 in_degree[i] += 1;
240 }
241 }
243 }
244
245 let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
246 let mut visited = 0usize;
247
248 while let Some(node) = queue.pop() {
249 visited += 1;
250 for &neighbor in &adj[node] {
251 in_degree[neighbor] -= 1;
252 if in_degree[neighbor] == 0 {
253 queue.push(neighbor);
254 }
255 }
256 }
257
258 if visited == n {
259 Ok(())
260 } else {
261 let cycle_vars: Vec<&str> = (0..n)
263 .filter(|&i| in_degree[i] > 0)
264 .map(|i| self.vars[i].key.as_str())
265 .collect();
266 Err(ConfigError::new(format!(
267 "Cycle detected among computed variables: {cycle_vars:?}"
268 )))
269 }
270 }
271
272 pub fn len(&self) -> usize {
274 self.vars.len()
275 }
276
277 pub fn is_empty(&self) -> bool {
279 self.vars.is_empty()
280 }
281
282 pub fn describe(&self) -> Vec<ComputedContract> {
284 self.vars
285 .iter()
286 .map(|var| ComputedContract {
287 key: var.key.clone(),
288 dependencies: var.dependencies.clone(),
289 })
290 .collect()
291 }
292
293 fn topo_sort(&mut self) -> Result<(), ConfigError> {
299 let n = self.vars.len();
300
301 for var in &self.vars {
303 if var.dependencies.contains(&var.key) {
304 return Err(ConfigError::new(format!(
305 "Cycle detected among computed variables: {:?}",
306 vec![var.key.as_str()]
307 )));
308 }
309 }
310
311 if n <= 1 {
312 return Ok(());
313 }
314
315 let key_to_idx: HashMap<&str, usize> = self
317 .vars
318 .iter()
319 .enumerate()
320 .map(|(i, v)| (v.key.as_str(), i))
321 .collect();
322
323 let mut in_degree = vec![0usize; n];
326 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
327
328 for (i, var) in self.vars.iter().enumerate() {
329 for dep in &var.dependencies {
330 if let Some(&dep_idx) = key_to_idx.get(dep.as_str()) {
331 adj[dep_idx].push(i);
332 in_degree[i] += 1;
333 }
334 }
335 }
336
337 let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
339 let mut order: Vec<usize> = Vec::with_capacity(n);
340
341 while let Some(node) = queue.pop() {
342 order.push(node);
343 for &neighbor in &adj[node] {
344 in_degree[neighbor] -= 1;
345 if in_degree[neighbor] == 0 {
346 queue.push(neighbor);
347 }
348 }
349 }
350
351 if order.len() != n {
352 let cycle_vars: Vec<&str> = (0..n)
353 .filter(|&i| in_degree[i] > 0)
354 .map(|i| self.vars[i].key.as_str())
355 .collect();
356 return Err(ConfigError::new(format!(
357 "Cycle detected among computed variables: {cycle_vars:?}"
358 )));
359 }
360
361 let mut slots: Vec<Option<ComputedVar>> = self.vars.drain(..).map(Some).collect();
364 for &idx in &order {
365 if let Some(var) = slots[idx].take() {
366 self.vars.push(var);
367 }
368 }
369 Ok(())
370 }
371
372 fn rebuild_dep_index(&mut self) {
374 self.dep_index.clear();
375 for (i, var) in self.vars.iter().enumerate() {
376 for dep in &var.dependencies {
377 self.dep_index.entry(dep.clone()).or_default().push(i);
378 }
379 }
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use serde_json::json;
387
388 fn counted(
392 key: &str,
393 deps: &[&str],
394 calls: Arc<std::sync::atomic::AtomicUsize>,
395 f: impl Fn(&State) -> Option<Value> + Send + Sync + 'static,
396 ) -> ComputedVar {
397 ComputedVar {
398 key: key.into(),
399 dependencies: deps.iter().map(|d| (*d).into()).collect(),
400 compute: Arc::new(move |state| {
401 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
402 f(state)
403 }),
404 }
405 }
406
407 #[test]
408 fn recompute_changed_evaluates_only_what_the_journal_says_changed() {
409 use std::sync::atomic::{AtomicUsize, Ordering};
410 let doubled_calls = Arc::new(AtomicUsize::new(0));
411 let label_calls = Arc::new(AtomicUsize::new(0));
412 let mut registry = ComputedRegistry::new();
413 registry
414 .register(counted(
415 "doubled",
416 &["app:count"],
417 doubled_calls.clone(),
418 |s| Some(json!(s.get::<i64>("app:count")? * 2)),
419 ))
420 .unwrap();
421 registry
422 .register(counted("label", &["app:name"], label_calls.clone(), |s| {
423 Some(json!(s.get::<String>("app:name")?.to_uppercase()))
424 }))
425 .unwrap();
426
427 let state = State::new();
428 let _ = state.set("app:count", 2);
429 let _ = state.set("app:name", "ada");
430
431 registry.recompute_changed(&state);
433 assert_eq!(doubled_calls.load(Ordering::SeqCst), 1);
434 assert_eq!(label_calls.load(Ordering::SeqCst), 1);
435
436 let cursor = state.mutation_cursor();
438 assert!(registry.recompute_changed(&state).is_empty());
439 assert_eq!(doubled_calls.load(Ordering::SeqCst), 1);
440 assert_eq!(
441 state.mutation_cursor(),
442 cursor,
443 "an unchanged pass writes nothing"
444 );
445
446 let _ = state.set("app:count", 5);
448 assert_eq!(registry.recompute_changed(&state), vec!["doubled"]);
449 assert_eq!(state.get::<i64>("derived:doubled"), Some(10));
450 assert_eq!(doubled_calls.load(Ordering::SeqCst), 2);
451 assert_eq!(label_calls.load(Ordering::SeqCst), 1);
452 }
453
454 #[test]
455 fn recompute_changed_follows_chains_and_rescans_after_a_journal_gap() {
456 let mut registry = ComputedRegistry::new();
457 registry
458 .register(ComputedVar {
459 key: "doubled".into(),
460 dependencies: vec!["app:count".into()],
461 compute: Arc::new(|s| Some(json!(s.get::<i64>("app:count")? * 2))),
462 })
463 .unwrap();
464 registry
465 .register(ComputedVar {
466 key: "big".into(),
467 dependencies: vec!["doubled".into()],
468 compute: Arc::new(|s| Some(json!(s.get::<i64>("derived:doubled")? > 10))),
469 })
470 .unwrap();
471
472 let state = State::new();
473 let _ = state.set("app:count", 1);
474 registry.recompute_changed(&state);
475 assert_eq!(state.get::<bool>("derived:big"), Some(false));
476
477 let _ = state.set("app:count", 6);
478 registry.recompute_changed(&state);
479 assert_eq!(
480 state.get::<bool>("derived:big"),
481 Some(true),
482 "chain propagates"
483 );
484
485 let _ = state.set("app:count", 1);
487 state.drain_mutations();
488 registry.recompute_changed(&state);
489 assert_eq!(state.get::<i64>("derived:doubled"), Some(2));
490 assert_eq!(state.get::<bool>("derived:big"), Some(false));
491 }
492
493 #[test]
494 fn a_var_without_declared_dependencies_runs_every_pass() {
495 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
496 let mut registry = ComputedRegistry::new();
497 registry
498 .register(counted("anything", &[], calls.clone(), |_| Some(json!(1))))
499 .unwrap();
500 let state = State::new();
501 registry.recompute_changed(&state);
502 registry.recompute_changed(&state);
503 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
504 }
505
506 #[test]
507 fn single_var_register_and_recompute() {
508 let mut registry = ComputedRegistry::new();
509 registry
510 .register(ComputedVar {
511 key: "doubled".into(),
512 dependencies: vec!["app:count".into()],
513 compute: Arc::new(|state| {
514 let count: i64 = state.get("app:count")?;
515 Some(json!(count * 2))
516 }),
517 })
518 .unwrap();
519
520 let state = State::new();
521 let _ = state.set("app:count", 5);
522
523 let changed = registry.recompute(&state);
524 assert_eq!(changed, vec!["doubled"]);
525 assert_eq!(state.get::<i64>("derived:doubled"), Some(10));
526 }
527
528 #[test]
531 fn dependency_ordering() {
532 let mut registry = ComputedRegistry::new();
533
534 registry
536 .register(ComputedVar {
537 key: "derived_from_base".into(),
538 dependencies: vec!["base".into()],
539 compute: Arc::new(|state| {
540 let base: i64 = state.get("derived:base")?;
541 Some(json!(base + 100))
542 }),
543 })
544 .unwrap();
545
546 registry
548 .register(ComputedVar {
549 key: "base".into(),
550 dependencies: vec!["app:input".into()],
551 compute: Arc::new(|state| {
552 let input: i64 = state.get("app:input")?;
553 Some(json!(input * 2))
554 }),
555 })
556 .unwrap();
557
558 let state = State::new();
559 let _ = state.set("app:input", 3);
560
561 let changed = registry.recompute(&state);
562 assert_eq!(state.get::<i64>("derived:base"), Some(6));
564 assert_eq!(state.get::<i64>("derived:derived_from_base"), Some(106));
565 assert!(changed.contains(&"base".to_string()));
566 assert!(changed.contains(&"derived_from_base".to_string()));
567 }
568
569 #[test]
572 fn cycle_detection_is_an_error_and_rolls_back() {
573 let mut registry = ComputedRegistry::new();
574 registry
575 .register(ComputedVar {
576 key: "a".into(),
577 dependencies: vec!["b".into()],
578 compute: Arc::new(|_| Some(json!(1))),
579 })
580 .unwrap();
581 let result = registry.register(ComputedVar {
582 key: "b".into(),
583 dependencies: vec!["a".into()],
584 compute: Arc::new(|_| Some(json!(2))),
585 });
586 let err = result.expect_err("cycle must be rejected");
587 assert!(err.to_string().contains("Cycle detected"), "{err}");
588 assert_eq!(registry.len(), 1);
590 assert!(registry.validate().is_ok());
591 }
592
593 #[test]
596 fn recompute_returns_only_changed_keys() {
597 let mut registry = ComputedRegistry::new();
598 registry
599 .register(ComputedVar {
600 key: "level".into(),
601 dependencies: vec!["app:score".into()],
602 compute: Arc::new(|state| {
603 let score: f64 = state.get("app:score")?;
604 if score > 0.5 {
605 Some(json!("high"))
606 } else {
607 Some(json!("low"))
608 }
609 }),
610 })
611 .unwrap();
612
613 let state = State::new();
614 let _ = state.set("app:score", 0.8);
615
616 let changed = registry.recompute(&state);
618 assert_eq!(changed, vec!["level"]);
619
620 let changed = registry.recompute(&state);
622 assert!(changed.is_empty());
623
624 let _ = state.set("app:score", 0.2);
626 let changed = registry.recompute(&state);
627 assert_eq!(changed, vec!["level"]);
628 assert_eq!(
629 state.get::<String>("derived:level"),
630 Some("low".to_string())
631 );
632 }
633
634 #[test]
637 fn recompute_affected_only_recomputes_affected() {
638 let call_count_a = Arc::new(std::sync::atomic::AtomicUsize::new(0));
639 let call_count_b = Arc::new(std::sync::atomic::AtomicUsize::new(0));
640
641 let cc_a = call_count_a.clone();
642 let cc_b = call_count_b.clone();
643
644 let mut registry = ComputedRegistry::new();
645 registry
646 .register(ComputedVar {
647 key: "from_x".into(),
648 dependencies: vec!["app:x".into()],
649 compute: Arc::new(move |state| {
650 cc_a.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
651 let x: i64 = state.get("app:x")?;
652 Some(json!(x + 1))
653 }),
654 })
655 .unwrap();
656 registry
657 .register(ComputedVar {
658 key: "from_y".into(),
659 dependencies: vec!["app:y".into()],
660 compute: Arc::new(move |state| {
661 cc_b.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
662 let y: i64 = state.get("app:y")?;
663 Some(json!(y + 1))
664 }),
665 })
666 .unwrap();
667
668 let state = State::new();
669 let _ = state.set("app:x", 10);
670 let _ = state.set("app:y", 20);
671
672 let changed = registry.recompute_affected(&state, &["app:x".into()]);
674 assert_eq!(changed, vec!["from_x"]);
675 assert_eq!(call_count_a.load(std::sync::atomic::Ordering::SeqCst), 1);
676 assert_eq!(call_count_b.load(std::sync::atomic::Ordering::SeqCst), 0);
677
678 assert_eq!(state.get::<i64>("derived:from_x"), Some(11));
679 assert_eq!(state.get_raw("derived:from_y"), None);
681 }
682
683 #[test]
686 fn validate_catches_cycles() {
687 let mut registry = ComputedRegistry::new();
688 registry.vars.push(ComputedVar {
690 key: "x".into(),
691 dependencies: vec!["y".into()],
692 compute: Arc::new(|_| Some(json!(1))),
693 });
694 registry.vars.push(ComputedVar {
695 key: "y".into(),
696 dependencies: vec!["x".into()],
697 compute: Arc::new(|_| Some(json!(2))),
698 });
699
700 let result = registry.validate();
701 assert!(result.is_err());
702 let msg = result.unwrap_err().to_string();
703 assert!(msg.contains("Cycle detected"));
704 }
705
706 #[test]
709 fn validate_succeeds_on_valid_graph() {
710 let mut registry = ComputedRegistry::new();
711 registry
712 .register(ComputedVar {
713 key: "a".into(),
714 dependencies: vec!["app:input".into()],
715 compute: Arc::new(|_| Some(json!(1))),
716 })
717 .unwrap();
718 registry
719 .register(ComputedVar {
720 key: "b".into(),
721 dependencies: vec!["a".into()],
722 compute: Arc::new(|_| Some(json!(2))),
723 })
724 .unwrap();
725
726 assert!(registry.validate().is_ok());
727 }
728
729 #[test]
732 fn compute_returning_none_skips_write() {
733 let mut registry = ComputedRegistry::new();
734 registry
735 .register(ComputedVar {
736 key: "maybe".into(),
737 dependencies: vec!["app:flag".into()],
738 compute: Arc::new(|state| {
739 let flag: bool = state.get("app:flag")?;
740 if flag { Some(json!("yes")) } else { None }
741 }),
742 })
743 .unwrap();
744
745 let state = State::new();
746 let changed = registry.recompute(&state);
748 assert!(changed.is_empty());
749 assert_eq!(state.get_raw("derived:maybe"), None);
750
751 let _ = state.set("app:flag", false);
753 let changed = registry.recompute(&state);
754 assert!(changed.is_empty());
755 assert_eq!(state.get_raw("derived:maybe"), None);
756
757 let _ = state.set("app:flag", true);
759 let changed = registry.recompute(&state);
760 assert_eq!(changed, vec!["maybe"]);
761 assert_eq!(
762 state.get::<String>("derived:maybe"),
763 Some("yes".to_string())
764 );
765 }
766
767 #[test]
770 fn diamond_dependency() {
771 let mut registry = ComputedRegistry::new();
779
780 registry
781 .register(ComputedVar {
782 key: "d".into(),
783 dependencies: vec!["app:root".into()],
784 compute: Arc::new(|state| {
785 let root: i64 = state.get("app:root")?;
786 Some(json!(root))
787 }),
788 })
789 .unwrap();
790
791 registry
792 .register(ComputedVar {
793 key: "a".into(),
794 dependencies: vec!["d".into()],
795 compute: Arc::new(|state| {
796 let d: i64 = state.get("derived:d")?;
797 Some(json!(d + 10))
798 }),
799 })
800 .unwrap();
801
802 registry
803 .register(ComputedVar {
804 key: "b".into(),
805 dependencies: vec!["d".into()],
806 compute: Arc::new(|state| {
807 let d: i64 = state.get("derived:d")?;
808 Some(json!(d + 20))
809 }),
810 })
811 .unwrap();
812
813 registry
814 .register(ComputedVar {
815 key: "c".into(),
816 dependencies: vec!["a".into(), "b".into()],
817 compute: Arc::new(|state| {
818 let a: i64 = state.get("derived:a")?;
819 let b: i64 = state.get("derived:b")?;
820 Some(json!(a + b))
821 }),
822 })
823 .unwrap();
824
825 let state = State::new();
826 let _ = state.set("app:root", 1);
827
828 let changed = registry.recompute(&state);
829 assert_eq!(state.get::<i64>("derived:d"), Some(1));
830 assert_eq!(state.get::<i64>("derived:a"), Some(11));
831 assert_eq!(state.get::<i64>("derived:b"), Some(21));
832 assert_eq!(state.get::<i64>("derived:c"), Some(32));
833 assert_eq!(changed.len(), 4);
834 }
835
836 #[test]
839 fn empty_registry_recompute_returns_empty() {
840 let registry = ComputedRegistry::new();
841 let state = State::new();
842 let changed = registry.recompute(&state);
843 assert!(changed.is_empty());
844 }
845
846 #[test]
849 fn len_and_is_empty() {
850 let mut registry = ComputedRegistry::new();
851 assert!(registry.is_empty());
852 assert_eq!(registry.len(), 0);
853
854 registry
855 .register(ComputedVar {
856 key: "x".into(),
857 dependencies: vec![],
858 compute: Arc::new(|_| Some(json!(1))),
859 })
860 .unwrap();
861 assert!(!registry.is_empty());
862 assert_eq!(registry.len(), 1);
863 }
864
865 #[test]
868 fn recompute_affected_diamond() {
869 let mut registry = ComputedRegistry::new();
870
871 registry
872 .register(ComputedVar {
873 key: "root_derived".into(),
874 dependencies: vec!["app:root".into()],
875 compute: Arc::new(|state| {
876 let r: i64 = state.get("app:root")?;
877 Some(json!(r * 10))
878 }),
879 })
880 .unwrap();
881
882 registry
883 .register(ComputedVar {
884 key: "leaf".into(),
885 dependencies: vec!["root_derived".into()],
886 compute: Arc::new(|state| {
887 let rd: i64 = state.get("derived:root_derived")?;
888 Some(json!(rd + 5))
889 }),
890 })
891 .unwrap();
892
893 let state = State::new();
894 let _ = state.set("app:root", 2);
895
896 registry.recompute(&state);
898 assert_eq!(state.get::<i64>("derived:root_derived"), Some(20));
899 assert_eq!(state.get::<i64>("derived:leaf"), Some(25));
900
901 let _ = state.set("app:root", 3);
903 let changed = registry.recompute_affected(&state, &["app:root".into()]);
904 assert!(changed.contains(&"root_derived".to_string()));
906 assert_eq!(state.get::<i64>("derived:root_derived"), Some(30));
907 assert!(changed.contains(&"leaf".to_string()));
910 assert_eq!(state.get::<i64>("derived:leaf"), Some(35));
911 }
912
913 #[test]
916 fn validate_empty_registry() {
917 let registry = ComputedRegistry::new();
918 assert!(registry.validate().is_ok());
919 }
920
921 #[test]
924 fn self_cycle_is_an_error() {
925 let mut registry = ComputedRegistry::new();
926 let err = registry
927 .register(ComputedVar {
928 key: "self_ref".into(),
929 dependencies: vec!["self_ref".into()],
930 compute: Arc::new(|_| Some(json!(1))),
931 })
932 .expect_err("self-cycle must be rejected");
933 assert!(err.to_string().contains("Cycle detected"), "{err}");
934 assert!(registry.is_empty());
935 }
936}