Skip to main content

fv_compute/
capability.rs

1//! The capability envelope: capabilities are **declared** in the manifest and
2//! **enforced** by the binding. The binding (a `stream`/derived compute vs an Action compute)
3//! declares which envelopes it accepts and *rejects* a transform that violates it — the
4//! governance boundary expressed as types, so it is checkable, not trusted.
5
6use crate::manifest::ImplKind;
7use serde::{Deserialize, Serialize};
8
9/// Where a compute must run. A capability-aware scheduler routes by this; GPU is
10/// container-only for now.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum Hardware {
14    #[default]
15    Cpu,
16    Gpu,
17}
18
19/// The declared capability envelope of a transform.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub struct CapabilityEnvelope {
22    #[serde(default)]
23    pub hardware: Hardware,
24    /// Same inputs → same outputs, no hidden state. Required for the cacheable/derived path.
25    pub deterministic: bool,
26    /// Performs side-effecting I/O (network, filesystem) beyond its inputs.
27    pub io: bool,
28    /// Can emit output incrementally per input chunk.
29    pub streaming: bool,
30}
31
32impl Default for CapabilityEnvelope {
33    /// The safe default for a data transform: pure, no I/O, batch, CPU.
34    fn default() -> Self {
35        Self {
36            hardware: Hardware::Cpu,
37            deterministic: true,
38            io: false,
39            streaming: false,
40        }
41    }
42}
43
44/// The binding a compute is used under — this decides which envelopes are legal.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Binding {
47    /// A pipeline `stream`/derived compute (derived props, hot path): STRICT — must be
48    /// pure, no I/O, and not an LLM (nondeterministic/effectful compute has no place on a
49    /// cacheable derived edge).
50    Stream,
51    /// Same strictness as `Stream`; named separately for call-site clarity (derived-property
52    /// materializer).
53    Derived,
54    /// An Action compute (effectful `(state, params) → EditSet`): PERMISSIVE — may declare
55    /// I/O, nondeterminism, and LLM impls, because governance wraps it (authorize → validate →
56    /// precondition → post-check → commit) rather than the compute touching the write path.
57    Action,
58}
59
60/// A specific reason a binding rejects an envelope. Kept as distinct variants so the publish
61/// gate can report exactly what is wrong.
62#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
63pub enum EnvelopeViolation {
64    #[error("binding {binding:?} forbids I/O (io=true); use an Action binding for effectful computes")]
65    IoNotAllowed { binding: Binding },
66    #[error("binding {binding:?} requires deterministic=true (a derived/stream edge must be cacheable)")]
67    NondeterministicNotAllowed { binding: Binding },
68    #[error("binding {binding:?} forbids impl=llm (nondeterministic/effectful); use an Action binding")]
69    LlmNotAllowed { binding: Binding },
70}
71
72impl Binding {
73    /// True for the strict data-plane bindings (`stream`/derived).
74    fn is_strict(self) -> bool {
75        matches!(self, Binding::Stream | Binding::Derived)
76    }
77
78    /// Does this binding accept a compute with the given envelope + impl kind? The single
79    /// enforcement point — every caller (pipeline runner, materializer, Action runtime) routes
80    /// through here, so the rule can't drift per call-site.
81    pub fn accepts(self, env: &CapabilityEnvelope, impl_kind: ImplKind) -> Result<(), EnvelopeViolation> {
82        if self.is_strict() {
83            if env.io {
84                return Err(EnvelopeViolation::IoNotAllowed { binding: self });
85            }
86            if !env.deterministic {
87                return Err(EnvelopeViolation::NondeterministicNotAllowed { binding: self });
88            }
89            if impl_kind == ImplKind::Llm {
90                return Err(EnvelopeViolation::LlmNotAllowed { binding: self });
91            }
92        }
93        Ok(())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn pure() -> CapabilityEnvelope {
102        CapabilityEnvelope::default()
103    }
104
105    #[test]
106    fn default_envelope_is_pure_cpu_batch() {
107        let e = pure();
108        assert_eq!(e.hardware, Hardware::Cpu);
109        assert!(e.deterministic && !e.io && !e.streaming);
110    }
111
112    #[test]
113    fn stream_binding_accepts_pure_compute() {
114        assert!(Binding::Stream.accepts(&pure(), ImplKind::Wasm).is_ok());
115        assert!(Binding::Derived.accepts(&pure(), ImplKind::Expression).is_ok());
116        assert!(Binding::Derived.accepts(&pure(), ImplKind::Builtin).is_ok());
117    }
118
119    #[test]
120    fn stream_binding_rejects_io_nondeterminism_and_llm() {
121        let io = CapabilityEnvelope { io: true, ..pure() };
122        assert_eq!(
123            Binding::Stream.accepts(&io, ImplKind::Wasm),
124            Err(EnvelopeViolation::IoNotAllowed {
125                binding: Binding::Stream
126            })
127        );
128        let nd = CapabilityEnvelope {
129            deterministic: false,
130            ..pure()
131        };
132        assert_eq!(
133            Binding::Derived.accepts(&nd, ImplKind::Container),
134            Err(EnvelopeViolation::NondeterministicNotAllowed {
135                binding: Binding::Derived
136            })
137        );
138        assert_eq!(
139            Binding::Stream.accepts(&pure(), ImplKind::Llm),
140            Err(EnvelopeViolation::LlmNotAllowed {
141                binding: Binding::Stream
142            })
143        );
144    }
145
146    #[test]
147    fn action_binding_permits_everything() {
148        let effectful = CapabilityEnvelope {
149            io: true,
150            deterministic: false,
151            ..pure()
152        };
153        assert!(Binding::Action.accepts(&effectful, ImplKind::Llm).is_ok());
154        assert!(Binding::Action.accepts(&effectful, ImplKind::Container).is_ok());
155    }
156}