Skip to main content

wm_core/
effects.rs

1//! Effect Row System — Inspired by Koka's Effect Types
2//!
3//! Every tool declares what resources it reads, writes, invokes, and
4//! whether it spawns external processes. This enables compile-time
5//! effect safety via Rust traits and runtime governance via Dharma.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// A resource that a tool may read from or write to.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Resource {
13    /// A specific LMDB galaxy database
14    Galaxy(String),
15    /// The karma ledger
16    KarmaLedger,
17    /// The Dharma rule engine
18    DharmaRules,
19    /// The Tantivy full-text index
20    SearchIndex,
21    /// The vector embedding store
22    VectorStore,
23    /// External network (HTTP, gRPC)
24    Network,
25    /// Local filesystem outside LMDB
26    Filesystem,
27    /// System process spawning
28    Process,
29    /// LLM inference (local or remote)
30    Inference,
31    /// User session state
32    Session,
33    /// The Gan Ying event bus (persisted to a JSONL log when enabled)
34    EventBus,
35}
36
37/// A capability that a tool may invoke.
38#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum Capability {
40    /// Memory read operations
41    MemoryRead,
42    /// Memory write operations
43    MemoryWrite,
44    /// Memory deletion
45    MemoryDelete,
46    /// Full-text search
47    Search,
48    /// Vector similarity search
49    VectorSearch,
50    /// Embedding generation
51    Embed,
52    /// LLM inference
53    LlmInfer,
54    /// Tool-to-tool delegation
55    Delegate,
56    /// External process execution
57    Execute,
58    /// Network request
59    NetworkRequest,
60    /// Dream cycle execution
61    Dream,
62    /// Consciousness update
63    CittaUpdate,
64}
65
66/// Estimated resource cost for a tool call.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct CostEstimate {
69    /// Estimated CPU time in nanoseconds (0 = unknown)
70    pub cpu_ns: u64,
71    /// Estimated memory touched in bytes (0 = unknown)
72    pub memory_bytes: u64,
73    /// Estimated disk I/O in bytes (0 = unknown)
74    pub disk_bytes: u64,
75    /// Estimated network I/O in bytes (0 = unknown)
76    pub network_bytes: u64,
77    /// Whether this tool is expensive enough to skip in Alpha/Theta modes
78    pub expensive: bool,
79}
80
81/// Kernel-sandbox declaration for a tool — the Landlock A→B seam.
82///
83/// v0 (declarative/audit-facing): the serve-level Landlock ruleset
84/// (`WM_LANDLOCK=1`) confines the whole process's write-class filesystem
85/// rights to the store root; this field records nothing enforced per tool.
86/// v1 (enforced): tools declaring [`Sandbox::StoreScoped`] become eligible
87/// to run on a dedicated Landlock-restricted thread, upgrading the pathway
88/// without reworking tool definitions.
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum Sandbox {
92    /// Tool runs with the process's ambient filesystem rights (default).
93    /// The serve-level ruleset still applies process-wide when enabled.
94    #[default]
95    Inherit,
96    /// Tool only touches paths beneath the store root — eligible for the
97    /// v1 per-tool restricted-thread pathway.
98    StoreScoped,
99}
100
101/// The effect row of a tool — what it does to the world.
102///
103/// Inspired by Koka's effect row system, this is checked at compile time
104/// via Rust trait bounds and at runtime by the Dharma governance layer.
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106pub struct EffectRow {
107    /// Resources this tool reads from
108    pub reads: Vec<Resource>,
109    /// Resources this tool writes to
110    pub writes: Vec<Resource>,
111    /// Capabilities this tool invokes
112    pub invokes: Vec<Capability>,
113    /// Whether this tool spawns external processes
114    pub spawns: bool,
115    /// Whether this tool is destructive (deletes/overwrites data).
116    /// Destructive tools require explicit confirmation via `confirm: true` in args.
117    pub destructive: bool,
118    /// Kernel-sandbox eligibility (Landlock A→B seam, declarative in v0).
119    #[serde(default)]
120    pub sandbox: Sandbox,
121    /// Estimated resource cost
122    pub cost: CostEstimate,
123}
124
125impl EffectRow {
126    /// Create an empty effect row (pure function)
127    #[must_use]
128    pub fn pure() -> Self {
129        Self::default()
130    }
131
132    /// Create a read-only effect row
133    #[must_use]
134    pub fn read_only(resources: Vec<Resource>) -> Self {
135        Self {
136            reads: resources,
137            writes: vec![],
138            invokes: vec![],
139            spawns: false,
140            destructive: false,
141            sandbox: Sandbox::Inherit,
142            cost: CostEstimate::default(),
143        }
144    }
145
146    /// Check if this effect row is compatible with a brain-wave state.
147    ///
148    /// In Alpha/Theta/Delta modes, expensive or write-heavy tools are
149    /// filtered out to conserve resources.
150    #[must_use]
151    pub fn is_available_in(&self, brain_wave: crate::BrainWave) -> bool {
152        use crate::BrainWave::{Alpha, Beta, Delta, Gamma, Theta};
153        match brain_wave {
154            Gamma => true,
155            Beta => true,
156            Alpha => !self.cost.expensive && self.writes.is_empty(),
157            Theta => !self.cost.expensive && self.writes.is_empty() && !self.spawns,
158            Delta => false, // Delta: no tools available, only wake on event
159        }
160    }
161
162    /// Check if this effect row conflicts with another (for parallel execution)
163    #[must_use]
164    pub fn conflicts_with(&self, other: &Self) -> bool {
165        // Write-write conflicts
166        for w in &self.writes {
167            if other.writes.contains(w) || other.reads.contains(w) {
168                return true;
169            }
170        }
171        for w in &other.writes {
172            if self.reads.contains(w) {
173                return true;
174            }
175        }
176        // Both spawn processes — could overload
177        if self.spawns && other.spawns {
178            return true;
179        }
180        false
181    }
182}
183
184impl fmt::Display for EffectRow {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        write!(
187            f,
188            "reads:{}, writes:{}, invokes:{}, spawns:{}",
189            self.reads.len(),
190            self.writes.len(),
191            self.invokes.len(),
192            self.spawns
193        )
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn pure_effect_has_no_side_effects() {
203        let e = EffectRow::pure();
204        assert!(e.reads.is_empty());
205        assert!(e.writes.is_empty());
206        assert!(!e.spawns);
207    }
208
209    #[test]
210    fn effect_conflict_detection() {
211        let writer = EffectRow {
212            writes: vec![Resource::Galaxy("citta".into())],
213            ..Default::default()
214        };
215        let reader = EffectRow {
216            reads: vec![Resource::Galaxy("citta".into())],
217            ..Default::default()
218        };
219        assert!(writer.conflicts_with(&reader));
220        assert!(reader.conflicts_with(&writer));
221
222        let other_reader = EffectRow {
223            reads: vec![Resource::Galaxy("codex".into())],
224            ..Default::default()
225        };
226        assert!(!reader.conflicts_with(&other_reader));
227    }
228
229    #[test]
230    fn brain_wave_filtering() {
231        use crate::BrainWave::*;
232        let expensive = EffectRow {
233            cost: CostEstimate {
234                expensive: true,
235                ..Default::default()
236            },
237            ..Default::default()
238        };
239        assert!(expensive.is_available_in(Gamma));
240        assert!(!expensive.is_available_in(Alpha));
241        assert!(!expensive.is_available_in(Delta));
242    }
243
244    #[test]
245    fn sandbox_seam_defaults_and_serializes() {
246        let row = EffectRow::pure();
247        assert_eq!(row.sandbox, Sandbox::Inherit);
248
249        let scoped = EffectRow {
250            sandbox: Sandbox::StoreScoped,
251            ..Default::default()
252        };
253        assert_eq!(scoped.sandbox, Sandbox::StoreScoped);
254
255        // Serialize round-trip, including payloads from before the field
256        // existed (serde default keeps old JSON deserializable).
257        let json = serde_json::to_string(&scoped).expect("serialize");
258        assert!(json.contains("store_scoped"));
259        let back: EffectRow = serde_json::from_str(&json).expect("deserialize");
260        assert_eq!(back.sandbox, Sandbox::StoreScoped);
261        let legacy: EffectRow = serde_json::from_str(
262            "{\"reads\":[],\"writes\":[],\"invokes\":[],\"spawns\":false,\"destructive\":false,\
263             \"cost\":{\"cpu_ns\":0,\"memory_bytes\":0,\"disk_bytes\":0,\"network_bytes\":0,\"expensive\":false}}",
264        )
265        .expect("legacy payload without sandbox field");
266        assert_eq!(legacy.sandbox, Sandbox::Inherit);
267    }
268
269    // ── Property-based tests (proptest) ─────────────────────────────
270
271    use crate::BrainWave;
272    use proptest::prelude::*;
273
274    fn arb_resource() -> impl Strategy<Value = Resource> {
275        prop_oneof![
276            Just(Resource::Galaxy("codex".into())),
277            Just(Resource::Galaxy("citta".into())),
278            Just(Resource::Filesystem),
279            Just(Resource::Network),
280            Just(Resource::Process),
281        ]
282    }
283
284    fn arb_effect_row() -> impl Strategy<Value = EffectRow> {
285        (
286            proptest::collection::vec(arb_resource(), 0..6),
287            proptest::collection::vec(arb_resource(), 0..6),
288            any::<bool>(),
289            any::<bool>(),
290        )
291            .prop_map(|(reads, writes, spawns, expensive)| EffectRow {
292                reads,
293                writes,
294                spawns,
295                cost: CostEstimate {
296                    expensive,
297                    ..Default::default()
298                },
299                ..Default::default()
300            })
301    }
302
303    proptest! {
304        /// Delta must always return false (no tools available in Delta).
305        #[test]
306        fn delta_blocks_all(effects in arb_effect_row()) {
307            prop_assert!(!effects.is_available_in(BrainWave::Delta));
308        }
309
310        /// Gamma must always return true (all tools available in Gamma).
311        #[test]
312        fn gamma_allows_all(effects in arb_effect_row()) {
313            prop_assert!(effects.is_available_in(BrainWave::Gamma));
314        }
315
316        /// Beta must always return true (all tools available in Beta).
317        #[test]
318        fn beta_allows_all(effects in arb_effect_row()) {
319            prop_assert!(effects.is_available_in(BrainWave::Beta));
320        }
321
322        /// Alpha blocks writes and expensive tools.
323        #[test]
324        fn alpha_blocks_writes_and_expensive(effects in arb_effect_row()) {
325            let result = effects.is_available_in(BrainWave::Alpha);
326            if !effects.writes.is_empty() || effects.cost.expensive {
327                prop_assert!(!result, "Alpha should block writes/expensive: {effects}");
328            } else {
329                prop_assert!(result, "Alpha should allow pure reads: {effects}");
330            }
331        }
332
333        /// Theta blocks writes, spawns, and expensive tools.
334        #[test]
335        fn theta_blocks_writes_spawns_expensive(effects in arb_effect_row()) {
336            let result = effects.is_available_in(BrainWave::Theta);
337            if !effects.writes.is_empty() || effects.cost.expensive || effects.spawns {
338                prop_assert!(!result, "Theta should block: {effects}");
339            } else {
340                prop_assert!(result, "Theta should allow pure reads: {effects}");
341            }
342        }
343
344        /// conflicts_with is symmetric: a.conflicts_with(b) == b.conflicts_with(a).
345        #[test]
346        fn conflicts_symmetric(a in arb_effect_row(), b in arb_effect_row()) {
347            let ab = a.conflicts_with(&b);
348            let ba = b.conflicts_with(&a);
349            prop_assert_eq!(ab, ba, "conflicts_with must be symmetric");
350        }
351
352        /// conflicts_with is reflexive for effect rows with writes or spawns.
353        #[test]
354        fn conflicts_self_with_writes_or_spawns(effects in arb_effect_row()) {
355            let self_conflict = effects.conflicts_with(&effects);
356            if !effects.writes.is_empty() || effects.spawns {
357                prop_assert!(self_conflict, "effect row with writes/spawns should conflict with itself");
358            } else {
359                prop_assert!(!self_conflict, "pure effect row should not conflict with itself");
360            }
361        }
362    }
363}