gemini_adk_rs/live/
computed.rs

1//! Computed (derived) state variables with dependency-ordered evaluation.
2//!
3//! Computed variables are pure functions of other state keys. The [`ComputedRegistry`]
4//! maintains a topologically sorted list of [`ComputedVar`]s so that dependencies are
5//! always evaluated before the variables that depend on them.
6
7use 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
19/// Closure computing a derived value from current state.
20pub type ComputeFn = Arc<dyn Fn(&State) -> Option<Value> + Send + Sync>;
21
22/// A computed state variable: a pure function of other state keys.
23///
24/// The `compute` closure receives the full [`State`] and returns an optional
25/// [`Value`]. When it returns `Some(value)`, the result is written to
26/// `derived:{key}` in state. When it returns `None`, the key is skipped
27/// (no write, no change detection).
28pub struct ComputedVar {
29    /// The state key this computed variable writes to (prefixed with `derived:`).
30    pub key: String,
31    /// State keys this variable depends on.
32    pub dependencies: Vec<String>,
33    /// Closure that computes the derived value from current state.
34    pub compute: ComputeFn,
35}
36
37/// Registry of computed variables with dependency-ordered evaluation.
38///
39/// Variables are kept in topological order: if var A depends on var B, then B
40/// appears before A in the internal list. This invariant is maintained at
41/// registration time using Kahn's algorithm.
42pub struct ComputedRegistry {
43    /// Topologically sorted computed variables.
44    vars: Vec<ComputedVar>,
45    /// Maps a state key to the indices (into `vars`) of computed variables
46    /// that list that key as a dependency.
47    dep_index: HashMap<String, Vec<usize>>,
48    /// Journal position [`recompute_changed`](Self::recompute_changed) has
49    /// read up to; `None` until its first (full) pass.
50    cursor: Mutex<Option<u64>>,
51}
52
53impl Default for ComputedRegistry {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl ComputedRegistry {
60    /// Create an empty registry.
61    pub fn new() -> Self {
62        Self {
63            vars: Vec::new(),
64            dep_index: HashMap::new(),
65            cursor: Mutex::new(None),
66        }
67    }
68
69    /// Register a computed variable (replacing any existing variable with the
70    /// same key). Re-sorts the internal list and rebuilds the dependency index.
71    ///
72    /// Returns a [`ConfigError`] naming the variables on the cycle if the new
73    /// variable would introduce a dependency cycle (including a self-cycle);
74    /// the registry is left unchanged in that case.
75    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            // Roll back so a rejected registration leaves no trace.
85            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    /// Recompute the variables whose inputs changed since the last call,
99    /// reading the changes from the state's mutation journal.
100    ///
101    /// The first call, and any call after the bounded journal has dropped
102    /// entries this registry has not read, recomputes everything. Returns the
103    /// keys whose derived values changed. This is what the control lane runs
104    /// after each turn.
105    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        // Skip this pass's own writes: their dependents were already
123        // evaluated transitively above.
124        *cursor = Some(state.mutation_cursor());
125        changed
126    }
127
128    /// Recompute all variables in dependency order. Returns the keys whose
129    /// derived values actually changed (old != new).
130    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    /// Recompute only the variables affected by the given changed keys.
141    /// Uses the dependency index for O(1) lookup of affected variables, then
142    /// evaluates them in topological order. Transitively propagates: if a
143    /// computed var changes, its dependents are also scheduled for recomputation.
144    /// Returns keys that actually changed.
145    pub fn recompute_affected(&self, state: &State, changed_keys: &[String]) -> Vec<String> {
146        self.recompute_indices(state, changed_keys, false)
147    }
148
149    /// [`recompute_affected`](Self::recompute_affected), optionally also
150    /// scheduling every variable that declares no dependencies (it may read
151    /// anything, so only a full pass is safe for it).
152    fn recompute_indices(
153        &self,
154        state: &State,
155        changed_keys: &[String],
156        include_undeclared: bool,
157    ) -> Vec<String> {
158        // Collect indices of affected vars transitively (deduplicated via bitmap).
159        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        // Seed the work queue with the initial changed keys.
171        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            // Look up vars that depend on this key directly.
176            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                        // This computed var's output (derived:<key>) might be
182                        // a dependency of other vars, so enqueue it.
183                        work_keys.push(self.vars[idx].key.clone());
184                    }
185                }
186            }
187        }
188
189        // Sort by topological order (indices are already in topo order).
190        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    /// Evaluate one variable; write `derived:{key}` only when the value
203    /// changed. Returns whether it changed.
204    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    /// Validate the dependency graph. Returns `Ok(())` if there are no cycles,
217    /// or a [`ConfigError`] naming the variables on the cycle.
218    pub fn validate(&self) -> Result<(), ConfigError> {
219        // Build adjacency from the current vars and run Kahn's algorithm.
220        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                // External dependencies (not in registry) are fine — ignore them.
242            }
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            // Find the vars involved in the cycle.
262            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    /// Returns the number of registered computed variables.
273    pub fn len(&self) -> usize {
274        self.vars.len()
275    }
276
277    /// Returns true if no computed variables are registered.
278    pub fn is_empty(&self) -> bool {
279        self.vars.is_empty()
280    }
281
282    /// Return serializable contract metadata for all computed variables.
283    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    // ── Internal helpers ──────────────────────────────────────────────────
294
295    /// Topologically sort `self.vars` in-place using Kahn's algorithm.
296    /// Fails (leaving `self.vars` in its previous order) if a cycle is
297    /// detected, including self-cycles.
298    fn topo_sort(&mut self) -> Result<(), ConfigError> {
299        let n = self.vars.len();
300
301        // Check for self-cycles (a var depending on itself).
302        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        // Map computed-var keys to their current index.
316        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        // Build adjacency list and in-degree array.
324        // Edge dep_idx -> i means "dep must come before i".
325        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        // Kahn's algorithm.
338        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        // Reorder vars according to topological sort.
362        // Use Option wrapping for safe index-based extraction.
363        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    /// Rebuild the `dep_index` mapping from dependency keys to var indices.
373    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    // ── 1. Single var register + recompute ──────────────────────────────
389
390    /// Counts evaluations, so a test can see what a pass recomputed.
391    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        // First pass: everything.
432        registry.recompute_changed(&state);
433        assert_eq!(doubled_calls.load(Ordering::SeqCst), 1);
434        assert_eq!(label_calls.load(Ordering::SeqCst), 1);
435
436        // Nothing changed: nothing recomputed, nothing written.
437        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        // Only the count changed: only `doubled` runs.
447        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        // Drop the journal: the next pass cannot trust it and rescans.
486        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    // ── 2. Dependency ordering (B depends on A) ────────────────────────
529
530    #[test]
531    fn dependency_ordering() {
532        let mut registry = ComputedRegistry::new();
533
534        // Register B first (depends on derived:base).
535        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        // Register A (base, no internal deps).
547        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        // base should be computed first (6), then derived_from_base (106).
563        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    // ── 3. Cycle detection (error, never a panic) ──────────────────────
570
571    #[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        // The rejected registration leaves no trace.
589        assert_eq!(registry.len(), 1);
590        assert!(registry.validate().is_ok());
591    }
592
593    // ── 4. Recompute returns only keys that changed ────────────────────
594
595    #[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        // First recompute: level is new, so it changed.
617        let changed = registry.recompute(&state);
618        assert_eq!(changed, vec!["level"]);
619
620        // Second recompute with same input: no change.
621        let changed = registry.recompute(&state);
622        assert!(changed.is_empty());
623
624        // Change input so derived value changes.
625        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    // ── 5. recompute_affected only recomputes affected vars ────────────
635
636    #[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        // Only app:x changed — should only recompute from_x.
673        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        // from_y was not computed, so derived:from_y should not exist.
680        assert_eq!(state.get_raw("derived:from_y"), None);
681    }
682
683    // ── 6. validate catches cycles ─────────────────────────────────────
684
685    #[test]
686    fn validate_catches_cycles() {
687        let mut registry = ComputedRegistry::new();
688        // Manually push vars without going through register (which would reject the cycle).
689        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    // ── 7. validate succeeds on valid graph ────────────────────────────
707
708    #[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    // ── 8. Compute returning None skips write ──────────────────────────
730
731    #[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        // app:flag not set → get returns None → compute returns None.
747        let changed = registry.recompute(&state);
748        assert!(changed.is_empty());
749        assert_eq!(state.get_raw("derived:maybe"), None);
750
751        // Set flag to false → compute returns None.
752        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        // Set flag to true → compute returns Some.
758        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    // ── 9. Diamond dependency ──────────────────────────────────────────
768
769    #[test]
770    fn diamond_dependency() {
771        // D is the root. A and B depend on D. C depends on A and B.
772        //
773        //     D
774        //    / \
775        //   A   B
776        //    \ /
777        //     C
778        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    // ── 10. Empty registry recompute returns empty vec ─────────────────
837
838    #[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    // ── Additional: len / is_empty ─────────────────────────────────────
847
848    #[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    // ── Additional: recompute_affected with diamond ────────────────────
866
867    #[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        // First full recompute to populate.
897        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        // Now change root, use recompute_affected.
902        let _ = state.set("app:root", 3);
903        let changed = registry.recompute_affected(&state, &["app:root".into()]);
904        // root_derived should be recomputed (depends on app:root).
905        assert!(changed.contains(&"root_derived".to_string()));
906        assert_eq!(state.get::<i64>("derived:root_derived"), Some(30));
907        // leaf depends on root_derived — it should be picked up via
908        // the dep_index entry for "root_derived".
909        assert!(changed.contains(&"leaf".to_string()));
910        assert_eq!(state.get::<i64>("derived:leaf"), Some(35));
911    }
912
913    // ── Additional: validate on empty registry ─────────────────────────
914
915    #[test]
916    fn validate_empty_registry() {
917        let registry = ComputedRegistry::new();
918        assert!(registry.validate().is_ok());
919    }
920
921    // ── Additional: self-cycle ──────────────────────────────────────────
922
923    #[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}