async-nodes 0.1.0

An asynchronous, type-safe node engine implementing the Bulk Synchronous Parallel (BSP) model: control-flow loops, recovery, suspension, and serializable graphs, with dataflow-once pipelines as a profile.
Documentation
//! Declarative edge conditions.
//!
//! Edges are *data*, not closures: every edge carries a [`Condition`] that the engine
//! evaluates against the consumer state `S` and the value store after the source node
//! completes. Named conditions resolve through a per-`S` registry the consumer fills at
//! graph construction, which is what makes routing predicates serializable and
//! GUI-renderable: the *name* serializes, the closure does not need to.
//!
//! The [`Condition::Custom`] variant is the escape hatch for the long tail of predicates.
//! It never truly serializes — only its label survives a definition round-trip, and
//! instantiation fails closed unless a condition with the same label is registered. Keep
//! the share of custom conditions low (target ≥90% named per consumer crate); the moment
//! half the edges in real graphs are custom closures, the GUI loses its representational
//! value.

use std::{collections::HashMap, fmt, sync::Arc};

use serde::{Deserialize, Serialize};

use super::run::ValueStore;

/// Registry key for a consumer-registered named condition, e.g. `"my-crate.has_tool_calls"`.
///
/// The name is the *stable cross-version identity* of the predicate: it is what
/// [`crate::def::ConditionDef`] serializes and what the GUI prints.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NamedConditionId(pub String);

impl fmt::Display for NamedConditionId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl From<&str> for NamedConditionId {
    fn from(s: &str) -> Self {
        Self(s.to_string())
    }
}

impl From<String> for NamedConditionId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

/// The shared signature of named condition predicates registered by consumers.
pub type ConditionFn<S> = Arc<dyn Fn(&S, &ValueStore) -> bool + Send + Sync>;

/// Map of condition name -> predicate, carried by the graph so condition evaluation needs
/// no engine-side global state.
pub type ConditionMap<S> = HashMap<String, ConditionFn<S>>;

/// Evaluation context handed to conditions when a source node completes (or fails).
///
/// Conditions never mutate anything; they read the consumer state and the value store,
/// plus two engine-computed flags describing how the source node's execution went.
pub struct ConditionEval<'a, S> {
    /// The branch state produced by the source node's execution (pre-merge).
    pub state: &'a S,
    /// The engine-owned value store, after the source's outputs were written.
    pub values: &'a ValueStore,
    /// Named condition registry for [`Condition::Named`] resolution.
    pub conditions: &'a ConditionMap<S>,
    /// `true` when the source node's execution was bypassed by `BspSkip` propagation or
    /// any value it produced carries the skip marker (dataflow profile).
    pub source_skipped: bool,
    /// `true` when evaluation happens in the *error* context: the source node failed and
    /// its recovery policy routed the failure to error edges instead of aborting the run.
    pub source_errored: bool,
}

/// A declarative predicate deciding whether an edge fires.
///
/// The engine evaluates a completed node's outgoing edges in declaration order; **every
/// edge whose condition matches fires** (emits an activation token). Write mutually
/// exclusive conditions for branch selection — e.g. an `is_done` / `not_done` named pair —
/// and prefer an [`Condition::Always`] fallback so a node can never strand into a dead-end.
///
/// The `S` parameter appears only in the [`Condition::Custom`] escape hatch; the
/// serializable subset is entirely `S`-free (see [`crate::def::ConditionDef`]).
pub enum Condition<S> {
    /// Always fires when the source completes successfully (the dataflow-profile default).
    Always,
    /// Never fires. Useful to park an edge in a saved graph without deleting it.
    Never,
    /// Fires when the source execution carried a `BspSkip` marker (dataflow profile).
    OnSkip,
    /// Fires when the source node *failed* and its recovery policy is
    /// [`crate::recovery::RecoveryPolicy::RouteToErrorEdge`]. Error paths become visible,
    /// GUI-renderable topology instead of implicit aborts.
    OnError,
    /// Consumer-registered named predicate over `&S` + value store.
    Named(NamedConditionId),
    /// Ordered short-circuit conjunction over conditions.
    And(Vec<Condition<S>>),
    /// Ordered short-circuit disjunction over conditions.
    Or(Vec<Condition<S>>),
    /// Negation of a condition.
    Not(Box<Condition<S>>),
    /// Escape hatch for the long tail. Never serializes its closure: the definition layer
    /// keeps only `label`, and instantiation fails closed unless a condition with that
    /// label is re-registered. The GUI renders it as an opaque gray box. Use sparingly.
    Custom {
        /// Human-readable label shown in the GUI and used as the re-registration key.
        label: String,
        /// The predicate over consumer state and value store.
        f: ConditionFn<S>,
    },
}

// Manual impls: the `S` parameter is only used behind `Arc`, so neither `Clone` nor
// `Debug` should require bounds on `S`.
impl<S> Clone for Condition<S> {
    fn clone(&self) -> Self {
        match self {
            Self::Always => Self::Always,
            Self::Never => Self::Never,
            Self::OnSkip => Self::OnSkip,
            Self::OnError => Self::OnError,
            Self::Named(id) => Self::Named(id.clone()),
            Self::And(conds) => Self::And(conds.clone()),
            Self::Or(conds) => Self::Or(conds.clone()),
            Self::Not(cond) => Self::Not(cond.clone()),
            Self::Custom { label, f } => Self::Custom {
                label: label.clone(),
                f: Arc::clone(f),
            },
        }
    }
}

impl<S> Condition<S> {
    /// Evaluates the condition in the given context.
    ///
    /// A missing [`Condition::Named`] registration evaluates to `false`; graph validation
    /// (`crate::engine::BspEngine::validate_with_report` / `BspGraph::from_def`) reports
    /// unknown names loudly before a run ever starts.
    #[must_use]
    pub fn eval(&self, ctx: &ConditionEval<'_, S>) -> bool {
        match self {
            Self::Always => true,
            Self::Never => false,
            Self::OnSkip => ctx.source_skipped,
            Self::OnError => ctx.source_errored,
            Self::Named(id) => ctx
                .conditions
                .get(&id.0)
                .is_some_and(|f| f(ctx.state, ctx.values)),
            Self::And(conds) => conds.iter().all(|c| c.eval(ctx)),
            Self::Or(conds) => conds.iter().any(|c| c.eval(ctx)),
            Self::Not(cond) => !cond.eval(ctx),
            Self::Custom { f, .. } => f(ctx.state, ctx.values),
        }
    }

    /// A short human-readable label for interceptor events and GUI badges.
    #[must_use]
    pub fn label(&self) -> String {
        match self {
            Self::Always => "always".to_string(),
            Self::Never => "never".to_string(),
            Self::OnSkip => "on_skip".to_string(),
            Self::OnError => "on_error".to_string(),
            Self::Named(id) => format!("named:{}", id.0),
            Self::And(conds) => format!(
                "and({})",
                conds.iter().map(Self::label).collect::<Vec<_>>().join(",")
            ),
            Self::Or(conds) => format!(
                "or({})",
                conds.iter().map(Self::label).collect::<Vec<_>>().join(",")
            ),
            Self::Not(cond) => format!("not({})", cond.label()),
            Self::Custom { label, .. } => format!("custom:{label}"),
        }
    }

    /// Collects every named-condition reference in this condition tree.
    ///
    /// Used by validation to check registry completeness.
    pub fn collect_named(&self, out: &mut Vec<String>) {
        match self {
            Self::Named(id) => out.push(id.0.clone()),
            Self::And(conds) | Self::Or(conds) => {
                for c in conds {
                    c.collect_named(out);
                }
            }
            Self::Not(cond) => cond.collect_named(out),
            Self::Always | Self::Never | Self::OnSkip | Self::OnError | Self::Custom { .. } => {}
        }
    }

    /// Collects the labels of every [`Condition::Custom`] leaf in this condition tree.
    ///
    /// Custom closures do not serialize; definitions carrying them fail closed at
    /// instantiation unless the label is registered as a named condition.
    pub fn collect_custom_labels(&self, out: &mut Vec<String>) {
        match self {
            Self::Custom { label, .. } => out.push(label.clone()),
            Self::And(conds) | Self::Or(conds) => {
                for c in conds {
                    c.collect_custom_labels(out);
                }
            }
            Self::Not(cond) => cond.collect_custom_labels(out),
            Self::Always | Self::Never | Self::OnSkip | Self::OnError | Self::Named(_) => {}
        }
    }
}

impl<S> fmt::Debug for Condition<S> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.label())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn eval_with<S>(cond: &Condition<S>, state: &S, conditions: &ConditionMap<S>) -> bool {
        let values = ValueStore::default();
        let ctx = ConditionEval {
            state,
            values: &values,
            conditions,
            source_skipped: false,
            source_errored: false,
        };
        cond.eval(&ctx)
    }

    #[test]
    fn test_always_and_never() {
        let conditions: ConditionMap<()> = HashMap::new();
        assert!(eval_with(&Condition::Always, &(), &conditions));
        assert!(!eval_with(&Condition::Never, &(), &conditions));
    }

    #[test]
    fn test_on_skip_and_on_error_flags() {
        let values = ValueStore::default();
        let conditions: ConditionMap<()> = HashMap::new();
        let ctx = ConditionEval {
            state: &(),
            values: &values,
            conditions: &conditions,
            source_skipped: true,
            source_errored: true,
        };
        assert!(Condition::<()>::OnSkip.eval(&ctx));
        assert!(Condition::<()>::OnError.eval(&ctx));
        assert!(!eval_with(&Condition::OnSkip, &(), &conditions));
        assert!(!eval_with(&Condition::OnError, &(), &conditions));
    }

    #[test]
    fn test_logical_combinators() {
        let conditions: ConditionMap<()> = HashMap::new();
        let cond: Condition<()> = Condition::And(vec![
            Condition::Always,
            Condition::Not(Box::new(Condition::Never)),
        ]);
        assert!(eval_with(&cond, &(), &conditions));

        let cond: Condition<()> = Condition::Or(vec![Condition::Never, Condition::Always]);
        assert!(eval_with(&cond, &(), &conditions));

        let cond: Condition<()> = Condition::And(vec![Condition::Always, Condition::Never]);
        assert!(!eval_with(&cond, &(), &conditions));
    }

    #[test]
    fn test_named_condition_resolution_and_missing_name() {
        let mut conditions: ConditionMap<u64> = HashMap::new();
        conditions.insert("is_big".to_string(), Arc::new(|s: &u64, _| *s > 10));
        assert!(eval_with(
            &Condition::Named("is_big".into()),
            &42_u64,
            &conditions
        ));
        // Unknown names fail closed (validation reports them before a run).
        assert!(!eval_with(
            &Condition::Named("nope".into()),
            &42_u64,
            &conditions
        ));
    }

    #[test]
    fn test_custom_condition_and_label_collection() {
        let cond: Condition<u64> = Condition::And(vec![
            Condition::Custom {
                label: "hand_rolled".to_string(),
                f: Arc::new(|s: &u64, _| *s == 7),
            },
            Condition::Named("registered".into()),
        ]);
        let mut custom = Vec::new();
        let mut named = Vec::new();
        cond.collect_custom_labels(&mut custom);
        cond.collect_named(&mut named);
        assert_eq!(custom, vec!["hand_rolled".to_string()]);
        assert_eq!(named, vec!["registered".to_string()]);

        // custom=true but named missing -> false
        let conditions: ConditionMap<u64> = HashMap::new();
        assert!(!eval_with(&cond, &7_u64, &conditions));
    }

    #[test]
    fn test_labels_for_events() {
        assert_eq!(Condition::<()>::Always.label(), "always");
        assert_eq!(Condition::<()>::Named("x".into()).label(), "named:x");
        assert_eq!(
            Condition::<()>::Not(Box::new(Condition::OnError)).label(),
            "not(on_error)"
        );
    }
}