Skip to main content

agentstatedeveloper_core/
transitive.rs

1//! Transitive effect propagation.
2//!
3//! Given declared effects per symbol and a call graph (callees edges),
4//! compute each symbol's *transitive* effects — the union of declared
5//! effects across its callees, recursively, with `via` chains pointing
6//! at the immediate callee that surfaced each effect.
7//!
8//! Computed per strongly connected component of the call graph (see
9//! [`transitive_effects`]), so every member of a call cycle gets the same,
10//! complete answer whatever order the symbols are visited in.
11
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
13
14use std::collections::VecDeque;
15
16use crate::effects::EffectStore;
17use crate::error::Result;
18use crate::index::IndexStore;
19use crate::schema::{EffectCategory, EffectDecl, TransitiveEffect};
20
21/// Compute transitive effects for each symbol in `symbol_ids` and write
22/// them back via `effects.put_effects(...)`. Returns the count of
23/// symbols whose `EffectDecl.transitive` actually changed.
24pub fn propagate_transitive<I: IndexStore, E: EffectStore>(
25    index: &I,
26    effects: &E,
27    ref_name: &str,
28    symbol_ids: &[String],
29) -> Result<usize> {
30    // Load the part of the call graph these symbols reach, and the decls
31    // along it — the same reads the recursive walk made, done up front.
32    let mut callees_of: HashMap<String, Vec<String>> = HashMap::new();
33    let mut decls: HashMap<String, EffectDecl> = HashMap::new();
34    let mut queue: VecDeque<String> = symbol_ids.iter().cloned().collect();
35    while let Some(sym) = queue.pop_front() {
36        if callees_of.contains_key(&sym) {
37            continue;
38        }
39        if let Some(decl) = effects.get_effects(ref_name, &sym)? {
40            decls.insert(sym.clone(), decl);
41        }
42        let callees = index.get_callees(ref_name, &sym)?;
43        queue.extend(callees.iter().cloned());
44        callees_of.insert(sym, callees);
45    }
46
47    let mut updated: usize = 0;
48    for (sym, new_transitive) in transitive_effects(&callees_of, &decls, symbol_ids) {
49        // Compare against what is stored so unchanged decls are not
50        // rewritten.
51        let Some(mut decl) = decls.remove(&sym) else {
52            continue;
53        };
54        if !transitive_eq(&decl.transitive, &new_transitive) {
55            decl.transitive = new_transitive;
56            // Use the ASD agent id for the write; the engine doesn't
57            // surface a global "effect propagator" identity yet, so we
58            // borrow a stable string callers can grep for.
59            effects.put_effects(ref_name, &sym, &decl, "asd-transitive")?;
60            updated += 1;
61        }
62    }
63
64    Ok(updated)
65}
66
67/// Per-declarer transitive blast radius, derived entirely from the *stored*
68/// transitive data written by [`propagate_transitive`] — no call-graph
69/// reachability is recomputed here.
70///
71/// For each effect category `E` declared by at least one symbol, and each
72/// declarer `S` of `E`, the blast radius of `S` is the number of distinct
73/// *other* symbols whose stored `EffectDecl.transitive` carries `E` through
74/// a `via` chain that reaches `S`. The `via` field on a transitive entry
75/// names the immediate callee(s) that surfaced the effect, so walking
76/// "who lists X in their via for E" upward from the declarer reconstructs
77/// exactly the propagation paths `propagate_transitive` recorded.
78///
79/// Returns, per category, the declarers as `(symbol_id, blast_radius)`
80/// sorted by blast radius descending (ties broken by symbol_id ascending)
81/// so callers can take the top-N directly.
82pub fn declared_effect_blast_radius(
83    decls: &[(String, EffectDecl)],
84) -> HashMap<EffectCategory, Vec<(String, usize)>> {
85    // Declarers per category.
86    let mut declarers: HashMap<EffectCategory, Vec<&str>> = HashMap::new();
87    for (symbol_id, decl) in decls {
88        for e in &decl.declared {
89            declarers
90                .entry(e.effect.clone())
91                .or_default()
92                .push(symbol_id.as_str());
93        }
94    }
95
96    // Reverse propagation edges per category: child (via entry) → parents
97    // that inherited the effect through that child.
98    let mut inherits_via: HashMap<&EffectCategory, HashMap<&str, Vec<&str>>> = HashMap::new();
99    for (symbol_id, decl) in decls {
100        for t in &decl.transitive {
101            let edges = inherits_via.entry(&t.effect).or_default();
102            for child in &t.via {
103                edges
104                    .entry(child.as_str())
105                    .or_default()
106                    .push(symbol_id.as_str());
107            }
108        }
109    }
110
111    let mut out: HashMap<EffectCategory, Vec<(String, usize)>> = HashMap::new();
112    for (cat, cat_declarers) in declarers {
113        let edges = inherits_via.get(&cat);
114        let mut ranked: Vec<(String, usize)> = cat_declarers
115            .into_iter()
116            .map(|declarer| {
117                // BFS upward over the stored via-graph for this category.
118                let mut visited: HashSet<&str> = HashSet::new();
119                let mut queue: VecDeque<&str> = VecDeque::new();
120                visited.insert(declarer);
121                queue.push_back(declarer);
122                while let Some(cur) = queue.pop_front() {
123                    let Some(parents) = edges.and_then(|e| e.get(cur)) else {
124                        continue;
125                    };
126                    for parent in parents {
127                        if visited.insert(parent) {
128                            queue.push_back(parent);
129                        }
130                    }
131                }
132                // Exclude the declarer itself from its own radius.
133                (declarer.to_string(), visited.len() - 1)
134            })
135            .collect();
136        ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
137        out.insert(cat, ranked);
138    }
139    out
140}
141
142/// Each of `symbol_ids` with its transitive effects: every effect category
143/// reachable through a callee — declared by the callee or by anything it
144/// reaches — with the direct callees it is reachable through (`via`), minus
145/// the categories the symbol declares itself. Sorted by effect, then `via`.
146///
147/// Computed per strongly connected component of the call graph: every
148/// member of a call cycle reaches whatever any member reaches. The memoized
149/// DFS this replaces stopped at a cycle's first member, cached that partial
150/// answer for the others, and visited symbols in `HashMap` order — so cycle
151/// members could miss effects, and which ones changed between runs.
152pub fn transitive_effects(
153    callees_of: &HashMap<String, Vec<String>>,
154    decls: &HashMap<String, EffectDecl>,
155    symbol_ids: &[String],
156) -> Vec<(String, Vec<TransitiveEffect>)> {
157    let callees = |sym: &str| callees_of.get(sym).map_or(&[][..], Vec::as_slice);
158    let (components, component_of) = call_graph_components(symbol_ids, callees_of);
159
160    // Components come callees-first, so each one's callees are done before
161    // it is.
162    let mut reach: Vec<BTreeSet<EffectCategory>> = Vec::with_capacity(components.len());
163    for (id, members) in components.iter().enumerate() {
164        let mut reached = BTreeSet::new();
165        for &member in members {
166            if let Some(decl) = decls.get(member) {
167                reached.extend(decl.declared.iter().map(|e| e.effect.clone()));
168            }
169            for callee in callees(member) {
170                let other = component_of[callee.as_str()];
171                if other != id {
172                    reached.extend(reach[other].iter().cloned());
173                }
174            }
175        }
176        reach.push(reached);
177    }
178
179    symbol_ids
180        .iter()
181        .map(|sym| {
182            let own: HashSet<&EffectCategory> = decls
183                .get(sym)
184                .map(|d| d.declared.iter().map(|e| &e.effect).collect())
185                .unwrap_or_default();
186            let mut via: BTreeMap<EffectCategory, BTreeSet<String>> = BTreeMap::new();
187            for callee in callees(sym) {
188                // A call to itself reaches nothing its other callees don't.
189                if callee == sym {
190                    continue;
191                }
192                for category in &reach[component_of[callee.as_str()]] {
193                    if !own.contains(category) {
194                        via.entry(category.clone())
195                            .or_default()
196                            .insert(callee.clone());
197                    }
198                }
199            }
200            let mut transitive: Vec<TransitiveEffect> = via
201                .into_iter()
202                .map(|(effect, via)| TransitiveEffect {
203                    effect,
204                    via: via.into_iter().collect(),
205                    qualifiers: serde_json::Value::Null,
206                })
207                .collect();
208            transitive.sort_by(|a, b| {
209                a.effect
210                    .as_str()
211                    .cmp(b.effect.as_str())
212                    .then_with(|| a.via.cmp(&b.via))
213            });
214            (sym.clone(), transitive)
215        })
216        .collect()
217}
218
219/// The strongly connected components of the call graph reachable from
220/// `roots`, callees before callers, and each symbol's component. Tarjan's
221/// algorithm, iterative so a deep call chain cannot overflow the stack.
222fn call_graph_components<'a>(
223    roots: &'a [String],
224    callees_of: &'a HashMap<String, Vec<String>>,
225) -> (Vec<Vec<&'a str>>, HashMap<&'a str, usize>) {
226    #[derive(Default)]
227    struct Search<'a> {
228        order: HashMap<&'a str, usize>,
229        low: HashMap<&'a str, usize>,
230        stack: Vec<&'a str>,
231        on_stack: HashSet<&'a str>,
232        // (symbol, index of its next callee to visit)
233        work: Vec<(&'a str, usize)>,
234    }
235    impl<'a> Search<'a> {
236        fn visit(&mut self, sym: &'a str) {
237            let n = self.order.len();
238            self.order.insert(sym, n);
239            self.low.insert(sym, n);
240            self.stack.push(sym);
241            self.on_stack.insert(sym);
242            self.work.push((sym, 0));
243        }
244        fn lower(&mut self, sym: &'a str, to: usize) {
245            let low = self.low.get_mut(sym).expect("visited");
246            *low = (*low).min(to);
247        }
248    }
249
250    let mut search = Search::default();
251    let mut components: Vec<Vec<&str>> = Vec::new();
252    let mut component_of: HashMap<&str, usize> = HashMap::new();
253
254    for root in roots {
255        if search.order.contains_key(root.as_str()) {
256            continue;
257        }
258        search.visit(root.as_str());
259        while let Some(&(sym, next)) = search.work.last() {
260            let callees: &'a [String] = callees_of.get(sym).map_or(&[], Vec::as_slice);
261            if let Some(callee) = callees.get(next) {
262                search.work.last_mut().expect("non-empty").1 += 1;
263                let callee = callee.as_str();
264                if !search.order.contains_key(callee) {
265                    search.visit(callee);
266                } else if search.on_stack.contains(callee) {
267                    let reached = search.order[callee];
268                    search.lower(sym, reached);
269                }
270                continue;
271            }
272            search.work.pop();
273            if let Some(&(caller, _)) = search.work.last() {
274                let reached = search.low[sym];
275                search.lower(caller, reached);
276            }
277            if search.low[sym] == search.order[sym] {
278                let id = components.len();
279                let mut members = Vec::new();
280                loop {
281                    let member = search.stack.pop().expect("on the stack");
282                    search.on_stack.remove(member);
283                    component_of.insert(member, id);
284                    members.push(member);
285                    if member == sym {
286                        break;
287                    }
288                }
289                components.push(members);
290            }
291        }
292    }
293    (components, component_of)
294}
295
296/// Order-insensitive equality on TransitiveEffect lists. We already sort
297/// before writing, but reads from disk may pre-date a sort fix — so be
298/// defensive and compare as multisets keyed on (effect, sorted via).
299pub(crate) fn transitive_eq(a: &[TransitiveEffect], b: &[TransitiveEffect]) -> bool {
300    if a.len() != b.len() {
301        return false;
302    }
303    let to_key = |t: &TransitiveEffect| {
304        let mut via = t.via.clone();
305        via.sort();
306        (t.effect.clone(), via)
307    };
308    let mut a_keys: Vec<_> = a.iter().map(to_key).collect();
309    let mut b_keys: Vec<_> = b.iter().map(to_key).collect();
310    a_keys.sort();
311    b_keys.sort();
312    a_keys == b_keys
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::schema::Effect;
319
320    fn graph(edges: &[(&str, &str)]) -> HashMap<String, Vec<String>> {
321        let mut g: HashMap<String, Vec<String>> = HashMap::new();
322        for (from, to) in edges {
323            g.entry(from.to_string()).or_default().push(to.to_string());
324        }
325        g
326    }
327
328    fn decls(declared: &[(&str, &[EffectCategory])]) -> HashMap<String, EffectDecl> {
329        declared
330            .iter()
331            .map(|(sym, cats)| {
332                let decl = EffectDecl {
333                    symbol_id: sym.to_string(),
334                    declared: cats.iter().cloned().map(Effect::new).collect(),
335                    transitive: Vec::new(),
336                    verification: None,
337                    confidence: None,
338                    runtime: None,
339                    matched_policy: None,
340                };
341                (sym.to_string(), decl)
342            })
343            .collect()
344    }
345
346    /// symbol -> [(effect, via)], for comparing results.
347    fn summary(
348        out: Vec<(String, Vec<TransitiveEffect>)>,
349    ) -> BTreeMap<String, Vec<(String, Vec<String>)>> {
350        out.into_iter()
351            .map(|(sym, t)| {
352                let t = t
353                    .into_iter()
354                    .map(|t| (t.effect.as_str().to_string(), t.via))
355                    .collect();
356                (sym, t)
357            })
358            .collect()
359    }
360
361    fn ids(order: &[&str]) -> Vec<String> {
362        order.iter().map(|s| s.to_string()).collect()
363    }
364
365    /// a -> b -> a, and b -> t, which writes to the network. Both a and b
366    /// reach it. The memoized DFS gave whichever of a and b it reached
367    /// second nothing, depending on visit order.
368    #[test]
369    fn every_member_of_a_cycle_reaches_what_the_cycle_reaches() {
370        let g = graph(&[("a", "b"), ("b", "a"), ("b", "t")]);
371        let d = decls(&[("a", &[]), ("b", &[]), ("t", &[EffectCategory::IoNetOut])]);
372        let expected = BTreeMap::from([
373            (
374                "a".to_string(),
375                vec![("io.net.out".to_string(), ids(&["b"]))],
376            ),
377            (
378                "b".to_string(),
379                vec![("io.net.out".to_string(), ids(&["a", "t"]))],
380            ),
381            ("t".to_string(), vec![]),
382        ]);
383        for order in [
384            ["a", "b", "t"],
385            ["a", "t", "b"],
386            ["b", "a", "t"],
387            ["b", "t", "a"],
388            ["t", "a", "b"],
389            ["t", "b", "a"],
390        ] {
391            assert_eq!(
392                summary(transitive_effects(&g, &d, &ids(&order))),
393                expected,
394                "visited in order {order:?}"
395            );
396        }
397    }
398
399    #[test]
400    fn recursion_adds_nothing_via_itself() {
401        let g = graph(&[("s", "s"), ("s", "t")]);
402        let d = decls(&[("s", &[]), ("t", &[EffectCategory::IoNetOut])]);
403        assert_eq!(
404            summary(transitive_effects(&g, &d, &ids(&["s"])))["s"],
405            vec![("io.net.out".to_string(), ids(&["t"]))]
406        );
407    }
408
409    #[test]
410    fn a_symbols_own_effects_are_not_transitive() {
411        let g = graph(&[("a", "b")]);
412        let d = decls(&[
413            ("a", &[EffectCategory::IoNetOut]),
414            ("b", &[EffectCategory::IoNetOut, EffectCategory::IoFsRead]),
415        ]);
416        assert_eq!(
417            summary(transitive_effects(&g, &d, &ids(&["a"])))["a"],
418            vec![("io.fs.read".to_string(), ids(&["b"]))]
419        );
420    }
421
422    /// The recursive walk overflowed the stack on a deep enough chain.
423    #[test]
424    fn a_deep_call_chain_does_not_overflow_the_stack() {
425        const DEPTH: usize = 200_000;
426        let names: Vec<String> = (0..DEPTH).map(|i| format!("n{i}")).collect();
427        let mut g: HashMap<String, Vec<String>> = HashMap::new();
428        for pair in names.windows(2) {
429            g.insert(pair[0].clone(), vec![pair[1].clone()]);
430        }
431        let d = decls(&[(names[DEPTH - 1].as_str(), &[EffectCategory::IoNetOut])]);
432        let out = transitive_effects(&g, &d, &names[..1]);
433        assert_eq!(
434            summary(out)["n0"],
435            vec![("io.net.out".to_string(), ids(&["n1"]))]
436        );
437    }
438}