Skip to main content

noether_core/effects/
effect.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeSet;
3
4#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
5#[serde(tag = "effect")]
6pub enum Effect {
7    Cost {
8        cents: u64,
9    },
10    Fallible,
11    Llm {
12        model: String,
13    },
14    Network,
15    NonDeterministic,
16    /// Stage spawns, signals, or waits on OS-level processes.
17    Process,
18    Pure,
19    Unknown,
20}
21
22/// The variant name of an [`Effect`], without associated data.
23///
24/// Used by [`EffectPolicy`] to allow/deny whole classes of effects regardless
25/// of their parameters (e.g. deny all `Llm` calls irrespective of which model).
26#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum EffectKind {
29    Cost,
30    Fallible,
31    Llm,
32    Network,
33    NonDeterministic,
34    Process,
35    Pure,
36    Unknown,
37}
38
39impl Effect {
40    /// Return the kind (variant discriminant) of this effect, dropping any
41    /// associated data. Used for policy comparisons.
42    pub fn kind(&self) -> EffectKind {
43        match self {
44            Effect::Cost { .. } => EffectKind::Cost,
45            Effect::Fallible => EffectKind::Fallible,
46            Effect::Llm { .. } => EffectKind::Llm,
47            Effect::Network => EffectKind::Network,
48            Effect::NonDeterministic => EffectKind::NonDeterministic,
49            Effect::Process => EffectKind::Process,
50            Effect::Pure => EffectKind::Pure,
51            Effect::Unknown => EffectKind::Unknown,
52        }
53    }
54}
55
56impl std::fmt::Display for EffectKind {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        let s = match self {
59            EffectKind::Cost => "cost",
60            EffectKind::Fallible => "fallible",
61            EffectKind::Llm => "llm",
62            EffectKind::Network => "network",
63            EffectKind::NonDeterministic => "non-deterministic",
64            EffectKind::Process => "process",
65            EffectKind::Pure => "pure",
66            EffectKind::Unknown => "unknown",
67        };
68        write!(f, "{s}")
69    }
70}
71
72/// An ordered set of effects declared on a stage.
73///
74/// Uses `BTreeSet` for deterministic serialization order, which is
75/// critical for canonical JSON hashing.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct EffectSet {
78    effects: BTreeSet<Effect>,
79}
80
81impl EffectSet {
82    pub fn unknown() -> Self {
83        Self {
84            effects: BTreeSet::from([Effect::Unknown]),
85        }
86    }
87
88    pub fn pure() -> Self {
89        Self {
90            effects: BTreeSet::from([Effect::Pure]),
91        }
92    }
93
94    pub fn new(effects: impl IntoIterator<Item = Effect>) -> Self {
95        Self {
96            effects: effects.into_iter().collect(),
97        }
98    }
99
100    pub fn contains(&self, effect: &Effect) -> bool {
101        self.effects.contains(effect)
102    }
103
104    pub fn is_unknown(&self) -> bool {
105        self.effects.contains(&Effect::Unknown)
106    }
107
108    pub fn iter(&self) -> impl Iterator<Item = &Effect> {
109        self.effects.iter()
110    }
111}
112
113impl Default for EffectSet {
114    fn default() -> Self {
115        Self::unknown()
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn default_is_unknown() {
125        let es = EffectSet::default();
126        assert!(es.is_unknown());
127        assert!(es.contains(&Effect::Unknown));
128    }
129
130    #[test]
131    fn pure_does_not_contain_unknown() {
132        let es = EffectSet::pure();
133        assert!(!es.is_unknown());
134        assert!(es.contains(&Effect::Pure));
135    }
136
137    #[test]
138    fn serde_round_trip() {
139        let es = EffectSet::new([
140            Effect::Network,
141            Effect::Fallible,
142            Effect::Llm {
143                model: "claude-sonnet-4".into(),
144            },
145        ]);
146        let json = serde_json::to_string(&es).unwrap();
147        let deserialized: EffectSet = serde_json::from_str(&json).unwrap();
148        assert_eq!(es, deserialized);
149    }
150}