fv-compute 0.2.0

The FusionVault transform/compute contract: a transform is a typed function over Arrow data declared by a manifest, discovered by a registry, and run by a pluggable backend.
Documentation
//! The capability envelope: capabilities are **declared** in the manifest and
//! **enforced** by the binding. The binding (a `stream`/derived compute vs an Action compute)
//! declares which envelopes it accepts and *rejects* a transform that violates it — the
//! governance boundary expressed as types, so it is checkable, not trusted.

use crate::manifest::ImplKind;
use serde::{Deserialize, Serialize};

/// Where a compute must run. A capability-aware scheduler routes by this; GPU is
/// container-only for now.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Hardware {
    #[default]
    Cpu,
    Gpu,
}

/// The declared capability envelope of a transform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityEnvelope {
    #[serde(default)]
    pub hardware: Hardware,
    /// Same inputs → same outputs, no hidden state. Required for the cacheable/derived path.
    pub deterministic: bool,
    /// Performs side-effecting I/O (network, filesystem) beyond its inputs.
    pub io: bool,
    /// Can emit output incrementally per input chunk.
    pub streaming: bool,
}

impl Default for CapabilityEnvelope {
    /// The safe default for a data transform: pure, no I/O, batch, CPU.
    fn default() -> Self {
        Self {
            hardware: Hardware::Cpu,
            deterministic: true,
            io: false,
            streaming: false,
        }
    }
}

/// The binding a compute is used under — this decides which envelopes are legal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Binding {
    /// A pipeline `stream`/derived compute (derived props, hot path): STRICT — must be
    /// pure, no I/O, and not an LLM (nondeterministic/effectful compute has no place on a
    /// cacheable derived edge).
    Stream,
    /// Same strictness as `Stream`; named separately for call-site clarity (derived-property
    /// materializer).
    Derived,
    /// An Action compute (effectful `(state, params) → EditSet`): PERMISSIVE — may declare
    /// I/O, nondeterminism, and LLM impls, because governance wraps it (authorize → validate →
    /// precondition → post-check → commit) rather than the compute touching the write path.
    Action,
}

/// A specific reason a binding rejects an envelope. Kept as distinct variants so the publish
/// gate can report exactly what is wrong.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EnvelopeViolation {
    #[error("binding {binding:?} forbids I/O (io=true); use an Action binding for effectful computes")]
    IoNotAllowed { binding: Binding },
    #[error("binding {binding:?} requires deterministic=true (a derived/stream edge must be cacheable)")]
    NondeterministicNotAllowed { binding: Binding },
    #[error("binding {binding:?} forbids impl=llm (nondeterministic/effectful); use an Action binding")]
    LlmNotAllowed { binding: Binding },
}

impl Binding {
    /// True for the strict data-plane bindings (`stream`/derived).
    fn is_strict(self) -> bool {
        matches!(self, Binding::Stream | Binding::Derived)
    }

    /// Does this binding accept a compute with the given envelope + impl kind? The single
    /// enforcement point — every caller (pipeline runner, materializer, Action runtime) routes
    /// through here, so the rule can't drift per call-site.
    pub fn accepts(self, env: &CapabilityEnvelope, impl_kind: ImplKind) -> Result<(), EnvelopeViolation> {
        if self.is_strict() {
            if env.io {
                return Err(EnvelopeViolation::IoNotAllowed { binding: self });
            }
            if !env.deterministic {
                return Err(EnvelopeViolation::NondeterministicNotAllowed { binding: self });
            }
            if impl_kind == ImplKind::Llm {
                return Err(EnvelopeViolation::LlmNotAllowed { binding: self });
            }
        }
        Ok(())
    }
}

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

    fn pure() -> CapabilityEnvelope {
        CapabilityEnvelope::default()
    }

    #[test]
    fn default_envelope_is_pure_cpu_batch() {
        let e = pure();
        assert_eq!(e.hardware, Hardware::Cpu);
        assert!(e.deterministic && !e.io && !e.streaming);
    }

    #[test]
    fn stream_binding_accepts_pure_compute() {
        assert!(Binding::Stream.accepts(&pure(), ImplKind::Wasm).is_ok());
        assert!(Binding::Derived.accepts(&pure(), ImplKind::Expression).is_ok());
        assert!(Binding::Derived.accepts(&pure(), ImplKind::Builtin).is_ok());
    }

    #[test]
    fn stream_binding_rejects_io_nondeterminism_and_llm() {
        let io = CapabilityEnvelope { io: true, ..pure() };
        assert_eq!(
            Binding::Stream.accepts(&io, ImplKind::Wasm),
            Err(EnvelopeViolation::IoNotAllowed {
                binding: Binding::Stream
            })
        );
        let nd = CapabilityEnvelope {
            deterministic: false,
            ..pure()
        };
        assert_eq!(
            Binding::Derived.accepts(&nd, ImplKind::Container),
            Err(EnvelopeViolation::NondeterministicNotAllowed {
                binding: Binding::Derived
            })
        );
        assert_eq!(
            Binding::Stream.accepts(&pure(), ImplKind::Llm),
            Err(EnvelopeViolation::LlmNotAllowed {
                binding: Binding::Stream
            })
        );
    }

    #[test]
    fn action_binding_permits_everything() {
        let effectful = CapabilityEnvelope {
            io: true,
            deterministic: false,
            ..pure()
        };
        assert!(Binding::Action.accepts(&effectful, ImplKind::Llm).is_ok());
        assert!(Binding::Action.accepts(&effectful, ImplKind::Container).is_ok());
    }
}