Skip to main content

async_nodes/
condition.rs

1//! Declarative edge conditions.
2//!
3//! Edges are *data*, not closures: every edge carries a [`Condition`] that the engine
4//! evaluates against the consumer state `S` and the value store after the source node
5//! completes. Named conditions resolve through a per-`S` registry the consumer fills at
6//! graph construction, which is what makes routing predicates serializable and
7//! GUI-renderable: the *name* serializes, the closure does not need to.
8//!
9//! The [`Condition::Custom`] variant is the escape hatch for the long tail of predicates.
10//! It never truly serializes — only its label survives a definition round-trip, and
11//! instantiation fails closed unless a condition with the same label is registered. Keep
12//! the share of custom conditions low (target ≥90% named per consumer crate); the moment
13//! half the edges in real graphs are custom closures, the GUI loses its representational
14//! value.
15
16use std::{collections::HashMap, fmt, sync::Arc};
17
18use serde::{Deserialize, Serialize};
19
20use super::run::ValueStore;
21
22/// Registry key for a consumer-registered named condition, e.g. `"my-crate.has_tool_calls"`.
23///
24/// The name is the *stable cross-version identity* of the predicate: it is what
25/// [`crate::def::ConditionDef`] serializes and what the GUI prints.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct NamedConditionId(pub String);
28
29impl fmt::Display for NamedConditionId {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.write_str(&self.0)
32    }
33}
34
35impl From<&str> for NamedConditionId {
36    fn from(s: &str) -> Self {
37        Self(s.to_string())
38    }
39}
40
41impl From<String> for NamedConditionId {
42    fn from(s: String) -> Self {
43        Self(s)
44    }
45}
46
47/// The shared signature of named condition predicates registered by consumers.
48pub type ConditionFn<S> = Arc<dyn Fn(&S, &ValueStore) -> bool + Send + Sync>;
49
50/// Map of condition name -> predicate, carried by the graph so condition evaluation needs
51/// no engine-side global state.
52pub type ConditionMap<S> = HashMap<String, ConditionFn<S>>;
53
54/// Evaluation context handed to conditions when a source node completes (or fails).
55///
56/// Conditions never mutate anything; they read the consumer state and the value store,
57/// plus two engine-computed flags describing how the source node's execution went.
58pub struct ConditionEval<'a, S> {
59    /// The branch state produced by the source node's execution (pre-merge).
60    pub state: &'a S,
61    /// The engine-owned value store, after the source's outputs were written.
62    pub values: &'a ValueStore,
63    /// Named condition registry for [`Condition::Named`] resolution.
64    pub conditions: &'a ConditionMap<S>,
65    /// `true` when the source node's execution was bypassed by `BspSkip` propagation or
66    /// any value it produced carries the skip marker (dataflow profile).
67    pub source_skipped: bool,
68    /// `true` when evaluation happens in the *error* context: the source node failed and
69    /// its recovery policy routed the failure to error edges instead of aborting the run.
70    pub source_errored: bool,
71}
72
73/// A declarative predicate deciding whether an edge fires.
74///
75/// The engine evaluates a completed node's outgoing edges in declaration order; **every
76/// edge whose condition matches fires** (emits an activation token). Write mutually
77/// exclusive conditions for branch selection — e.g. an `is_done` / `not_done` named pair —
78/// and prefer an [`Condition::Always`] fallback so a node can never strand into a dead-end.
79///
80/// The `S` parameter appears only in the [`Condition::Custom`] escape hatch; the
81/// serializable subset is entirely `S`-free (see [`crate::def::ConditionDef`]).
82pub enum Condition<S> {
83    /// Always fires when the source completes successfully (the dataflow-profile default).
84    Always,
85    /// Never fires. Useful to park an edge in a saved graph without deleting it.
86    Never,
87    /// Fires when the source execution carried a `BspSkip` marker (dataflow profile).
88    OnSkip,
89    /// Fires when the source node *failed* and its recovery policy is
90    /// [`crate::recovery::RecoveryPolicy::RouteToErrorEdge`]. Error paths become visible,
91    /// GUI-renderable topology instead of implicit aborts.
92    OnError,
93    /// Consumer-registered named predicate over `&S` + value store.
94    Named(NamedConditionId),
95    /// Ordered short-circuit conjunction over conditions.
96    And(Vec<Condition<S>>),
97    /// Ordered short-circuit disjunction over conditions.
98    Or(Vec<Condition<S>>),
99    /// Negation of a condition.
100    Not(Box<Condition<S>>),
101    /// Escape hatch for the long tail. Never serializes its closure: the definition layer
102    /// keeps only `label`, and instantiation fails closed unless a condition with that
103    /// label is re-registered. The GUI renders it as an opaque gray box. Use sparingly.
104    Custom {
105        /// Human-readable label shown in the GUI and used as the re-registration key.
106        label: String,
107        /// The predicate over consumer state and value store.
108        f: ConditionFn<S>,
109    },
110}
111
112// Manual impls: the `S` parameter is only used behind `Arc`, so neither `Clone` nor
113// `Debug` should require bounds on `S`.
114impl<S> Clone for Condition<S> {
115    fn clone(&self) -> Self {
116        match self {
117            Self::Always => Self::Always,
118            Self::Never => Self::Never,
119            Self::OnSkip => Self::OnSkip,
120            Self::OnError => Self::OnError,
121            Self::Named(id) => Self::Named(id.clone()),
122            Self::And(conds) => Self::And(conds.clone()),
123            Self::Or(conds) => Self::Or(conds.clone()),
124            Self::Not(cond) => Self::Not(cond.clone()),
125            Self::Custom { label, f } => Self::Custom {
126                label: label.clone(),
127                f: Arc::clone(f),
128            },
129        }
130    }
131}
132
133impl<S> Condition<S> {
134    /// Evaluates the condition in the given context.
135    ///
136    /// A missing [`Condition::Named`] registration evaluates to `false`; graph validation
137    /// (`crate::engine::BspEngine::validate_with_report` / `BspGraph::from_def`) reports
138    /// unknown names loudly before a run ever starts.
139    #[must_use]
140    pub fn eval(&self, ctx: &ConditionEval<'_, S>) -> bool {
141        match self {
142            Self::Always => true,
143            Self::Never => false,
144            Self::OnSkip => ctx.source_skipped,
145            Self::OnError => ctx.source_errored,
146            Self::Named(id) => ctx
147                .conditions
148                .get(&id.0)
149                .is_some_and(|f| f(ctx.state, ctx.values)),
150            Self::And(conds) => conds.iter().all(|c| c.eval(ctx)),
151            Self::Or(conds) => conds.iter().any(|c| c.eval(ctx)),
152            Self::Not(cond) => !cond.eval(ctx),
153            Self::Custom { f, .. } => f(ctx.state, ctx.values),
154        }
155    }
156
157    /// A short human-readable label for interceptor events and GUI badges.
158    #[must_use]
159    pub fn label(&self) -> String {
160        match self {
161            Self::Always => "always".to_string(),
162            Self::Never => "never".to_string(),
163            Self::OnSkip => "on_skip".to_string(),
164            Self::OnError => "on_error".to_string(),
165            Self::Named(id) => format!("named:{}", id.0),
166            Self::And(conds) => format!(
167                "and({})",
168                conds.iter().map(Self::label).collect::<Vec<_>>().join(",")
169            ),
170            Self::Or(conds) => format!(
171                "or({})",
172                conds.iter().map(Self::label).collect::<Vec<_>>().join(",")
173            ),
174            Self::Not(cond) => format!("not({})", cond.label()),
175            Self::Custom { label, .. } => format!("custom:{label}"),
176        }
177    }
178
179    /// Collects every named-condition reference in this condition tree.
180    ///
181    /// Used by validation to check registry completeness.
182    pub fn collect_named(&self, out: &mut Vec<String>) {
183        match self {
184            Self::Named(id) => out.push(id.0.clone()),
185            Self::And(conds) | Self::Or(conds) => {
186                for c in conds {
187                    c.collect_named(out);
188                }
189            }
190            Self::Not(cond) => cond.collect_named(out),
191            Self::Always | Self::Never | Self::OnSkip | Self::OnError | Self::Custom { .. } => {}
192        }
193    }
194
195    /// Collects the labels of every [`Condition::Custom`] leaf in this condition tree.
196    ///
197    /// Custom closures do not serialize; definitions carrying them fail closed at
198    /// instantiation unless the label is registered as a named condition.
199    pub fn collect_custom_labels(&self, out: &mut Vec<String>) {
200        match self {
201            Self::Custom { label, .. } => out.push(label.clone()),
202            Self::And(conds) | Self::Or(conds) => {
203                for c in conds {
204                    c.collect_custom_labels(out);
205                }
206            }
207            Self::Not(cond) => cond.collect_custom_labels(out),
208            Self::Always | Self::Never | Self::OnSkip | Self::OnError | Self::Named(_) => {}
209        }
210    }
211}
212
213impl<S> fmt::Debug for Condition<S> {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        f.write_str(&self.label())
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn eval_with<S>(cond: &Condition<S>, state: &S, conditions: &ConditionMap<S>) -> bool {
224        let values = ValueStore::default();
225        let ctx = ConditionEval {
226            state,
227            values: &values,
228            conditions,
229            source_skipped: false,
230            source_errored: false,
231        };
232        cond.eval(&ctx)
233    }
234
235    #[test]
236    fn test_always_and_never() {
237        let conditions: ConditionMap<()> = HashMap::new();
238        assert!(eval_with(&Condition::Always, &(), &conditions));
239        assert!(!eval_with(&Condition::Never, &(), &conditions));
240    }
241
242    #[test]
243    fn test_on_skip_and_on_error_flags() {
244        let values = ValueStore::default();
245        let conditions: ConditionMap<()> = HashMap::new();
246        let ctx = ConditionEval {
247            state: &(),
248            values: &values,
249            conditions: &conditions,
250            source_skipped: true,
251            source_errored: true,
252        };
253        assert!(Condition::<()>::OnSkip.eval(&ctx));
254        assert!(Condition::<()>::OnError.eval(&ctx));
255        assert!(!eval_with(&Condition::OnSkip, &(), &conditions));
256        assert!(!eval_with(&Condition::OnError, &(), &conditions));
257    }
258
259    #[test]
260    fn test_logical_combinators() {
261        let conditions: ConditionMap<()> = HashMap::new();
262        let cond: Condition<()> = Condition::And(vec![
263            Condition::Always,
264            Condition::Not(Box::new(Condition::Never)),
265        ]);
266        assert!(eval_with(&cond, &(), &conditions));
267
268        let cond: Condition<()> = Condition::Or(vec![Condition::Never, Condition::Always]);
269        assert!(eval_with(&cond, &(), &conditions));
270
271        let cond: Condition<()> = Condition::And(vec![Condition::Always, Condition::Never]);
272        assert!(!eval_with(&cond, &(), &conditions));
273    }
274
275    #[test]
276    fn test_named_condition_resolution_and_missing_name() {
277        let mut conditions: ConditionMap<u64> = HashMap::new();
278        conditions.insert("is_big".to_string(), Arc::new(|s: &u64, _| *s > 10));
279        assert!(eval_with(
280            &Condition::Named("is_big".into()),
281            &42_u64,
282            &conditions
283        ));
284        // Unknown names fail closed (validation reports them before a run).
285        assert!(!eval_with(
286            &Condition::Named("nope".into()),
287            &42_u64,
288            &conditions
289        ));
290    }
291
292    #[test]
293    fn test_custom_condition_and_label_collection() {
294        let cond: Condition<u64> = Condition::And(vec![
295            Condition::Custom {
296                label: "hand_rolled".to_string(),
297                f: Arc::new(|s: &u64, _| *s == 7),
298            },
299            Condition::Named("registered".into()),
300        ]);
301        let mut custom = Vec::new();
302        let mut named = Vec::new();
303        cond.collect_custom_labels(&mut custom);
304        cond.collect_named(&mut named);
305        assert_eq!(custom, vec!["hand_rolled".to_string()]);
306        assert_eq!(named, vec!["registered".to_string()]);
307
308        // custom=true but named missing -> false
309        let conditions: ConditionMap<u64> = HashMap::new();
310        assert!(!eval_with(&cond, &7_u64, &conditions));
311    }
312
313    #[test]
314    fn test_labels_for_events() {
315        assert_eq!(Condition::<()>::Always.label(), "always");
316        assert_eq!(Condition::<()>::Named("x".into()).label(), "named:x");
317        assert_eq!(
318            Condition::<()>::Not(Box::new(Condition::OnError)).label(),
319            "not(on_error)"
320        );
321    }
322}