Skip to main content

wm_core/
tool.rs

1//! Tool Trait — The Fractal Meta-Tool Foundation
2//!
3//! Every tool in `WhiteMagic` implements this trait. Each tool self-tracks
4//! its call count, success rate, latency, and resource usage via atomic
5//! counters. The dispatch pipeline uses these stats to retire ineffective
6//! tools and promote hot ones.
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13/// Tool arguments — deserialized from JSON-RPC params.
14pub type Args = serde_json::Value;
15
16/// Tool output — serialized to JSON-RPC result.
17pub type Output = serde_json::Value;
18
19/// Atomic statistics tracked per-tool.
20///
21/// All fields are atomic, enabling lock-free updates from any thread.
22/// Update overhead is ~10ns per field (relaxed atomic store).
23#[derive(Debug, Default)]
24pub struct ToolStats {
25    /// Total number of calls
26    pub call_count: AtomicU64,
27    /// Number of successful calls
28    pub success_count: AtomicU64,
29    /// Central latency estimate in nanoseconds (exponential moving average
30    /// of recent call latencies — not an exact median).
31    pub p50_latency_ns: AtomicU64,
32    /// Highest latency seen in nanoseconds. The high-latency anomaly path
33    /// compares new calls against this peak.
34    pub peak_latency_ns: AtomicU64,
35    /// Total CPU time consumed in nanoseconds
36    pub cpu_time_ns: AtomicU64,
37    /// Total LMDB pages touched
38    pub lmdb_pages_touched: AtomicU64,
39    /// Unix timestamp of last use
40    pub last_used_unix: AtomicU64,
41    /// Karma-weighted effectiveness score (0.0 = useless, 1.0 = perfect)
42    pub effectiveness: std::sync::atomic::AtomicU32,
43}
44
45impl ToolStats {
46    /// Record a successful call.
47    pub fn record_success(&self, latency: Duration, cpu_time: Duration) {
48        self.call_count.fetch_add(1, Ordering::Relaxed);
49        self.success_count.fetch_add(1, Ordering::Relaxed);
50        let latency_ns = latency.as_nanos() as u64;
51        // Exponential moving average (alpha = 0.5) of recent latencies —
52        // an honest approximation of the central value without a histogram.
53        self.p50_latency_ns
54            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |old| {
55                Some(old / 2 + latency_ns / 2)
56            })
57            .ok();
58        // Peak latency: the high-latency anomaly path compares new calls
59        // against the worst latency ever seen.
60        self.peak_latency_ns
61            .fetch_max(latency_ns, Ordering::Relaxed);
62        self.cpu_time_ns
63            .fetch_add(cpu_time.as_nanos() as u64, Ordering::Relaxed);
64        self.last_used_unix.store(
65            std::time::SystemTime::now()
66                .duration_since(std::time::UNIX_EPOCH)
67                .unwrap_or_default()
68                .as_secs(),
69            Ordering::Relaxed,
70        );
71        // Auto-update effectiveness from success rate
72        self.update_effectiveness();
73    }
74
75    /// Record a failed call.
76    pub fn record_failure(&self, latency: Duration) {
77        self.call_count.fetch_add(1, Ordering::Relaxed);
78        let latency_ns = latency.as_nanos() as u64;
79        self.p50_latency_ns
80            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |old| {
81                Some(old / 2 + latency_ns / 2)
82            })
83            .ok();
84        self.peak_latency_ns
85            .fetch_max(latency_ns, Ordering::Relaxed);
86        self.last_used_unix.store(
87            std::time::SystemTime::now()
88                .duration_since(std::time::UNIX_EPOCH)
89                .unwrap_or_default()
90                .as_secs(),
91            Ordering::Relaxed,
92        );
93        // Auto-update effectiveness from success rate
94        self.update_effectiveness();
95    }
96
97    /// Get the success rate (0.0 to 1.0).
98    pub fn success_rate(&self) -> f64 {
99        let calls = self.call_count.load(Ordering::Relaxed);
100        if calls == 0 {
101            return 1.0;
102        }
103        let successes = self.success_count.load(Ordering::Relaxed);
104        successes as f64 / calls as f64
105    }
106
107    /// Get the effectiveness score as a float (0.0 to 1.0).
108    pub fn effectiveness_f32(&self) -> f32 {
109        f32::from_bits(self.effectiveness.load(Ordering::Relaxed))
110    }
111
112    /// Set the effectiveness score.
113    pub fn set_effectiveness(&self, score: f32) {
114        self.effectiveness.store(score.to_bits(), Ordering::Relaxed);
115    }
116
117    /// Auto-update effectiveness from the current success rate.
118    ///
119    /// Effectiveness = success_count / call_count, clamped to [0, 1].
120    /// This ensures the anomaly detector has accurate data without
121    /// requiring external code to call `set_effectiveness`.
122    fn update_effectiveness(&self) {
123        let calls = self.call_count.load(Ordering::Relaxed);
124        if calls == 0 {
125            return;
126        }
127        let successes = self.success_count.load(Ordering::Relaxed);
128        let rate = (successes as f32) / (calls as f32);
129        self.effectiveness.store(rate.to_bits(), Ordering::Relaxed);
130    }
131
132    /// Whether this tool should be retired (low effectiveness after enough calls).
133    pub fn should_retire(&self, min_calls: u64, threshold: f32) -> bool {
134        let calls = self.call_count.load(Ordering::Relaxed);
135        if calls < min_calls {
136            return false;
137        }
138        self.effectiveness_f32() < threshold
139    }
140
141    /// Whether this tool is hot (high call count).
142    pub fn is_hot(&self, threshold: u64) -> bool {
143        self.call_count.load(Ordering::Relaxed) > threshold
144    }
145
146    /// Restore stats from a persisted snapshot.
147    ///
148    /// Used on startup to rehydrate cross-restart usage data so tools
149    /// like `tools.usage_report` can rank on cumulative history instead
150    /// of only the current process lifetime. Counters are overwritten,
151    /// not merged — call this before any dispatches are recorded.
152    pub fn restore(&self, snap: &ToolStatsSnapshot) {
153        self.call_count.store(snap.call_count, Ordering::Relaxed);
154        self.success_count
155            .store(snap.success_count, Ordering::Relaxed);
156        self.p50_latency_ns
157            .store(snap.p50_latency_ns, Ordering::Relaxed);
158        self.peak_latency_ns
159            .store(snap.peak_latency_ns, Ordering::Relaxed);
160        self.cpu_time_ns.store(snap.cpu_time_ns, Ordering::Relaxed);
161        self.lmdb_pages_touched
162            .store(snap.lmdb_pages_touched, Ordering::Relaxed);
163        self.last_used_unix
164            .store(snap.last_used_unix, Ordering::Relaxed);
165        self.effectiveness
166            .store(snap.effectiveness.to_bits(), Ordering::Relaxed);
167    }
168
169    /// Get a snapshot of all stats as a serializable struct.
170    pub fn snapshot(&self) -> ToolStatsSnapshot {
171        ToolStatsSnapshot {
172            call_count: self.call_count.load(Ordering::Relaxed),
173            success_count: self.success_count.load(Ordering::Relaxed),
174            p50_latency_ns: self.p50_latency_ns.load(Ordering::Relaxed),
175            peak_latency_ns: self.peak_latency_ns.load(Ordering::Relaxed),
176            cpu_time_ns: self.cpu_time_ns.load(Ordering::Relaxed),
177            lmdb_pages_touched: self.lmdb_pages_touched.load(Ordering::Relaxed),
178            last_used_unix: self.last_used_unix.load(Ordering::Relaxed),
179            effectiveness: self.effectiveness_f32(),
180        }
181    }
182}
183
184/// A serializable snapshot of tool statistics.
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct ToolStatsSnapshot {
187    /// Total number of calls
188    pub call_count: u64,
189    /// Number of successful calls
190    pub success_count: u64,
191    /// Central latency estimate in nanoseconds (EWMA).
192    pub p50_latency_ns: u64,
193    /// Highest latency seen in nanoseconds.
194    pub peak_latency_ns: u64,
195    /// Total CPU time consumed in nanoseconds
196    pub cpu_time_ns: u64,
197    /// Total LMDB pages touched
198    pub lmdb_pages_touched: u64,
199    /// Unix timestamp of last use
200    pub last_used_unix: u64,
201    /// Karma-weighted effectiveness score (0.0 to 1.0)
202    pub effectiveness: f32,
203}
204
205/// The core tool trait. Every `WhiteMagic` tool implements this.
206///
207/// Tools declare their Gana affiliation and effect row, then implement
208/// the `call` method. The dispatch pipeline handles routing, governance,
209/// and statistics tracking.
210#[async_trait]
211pub trait Tool: Send + Sync {
212    /// Unique tool name (e.g., "memory.create", "search.hybrid")
213    fn name(&self) -> &str;
214
215    /// Which Gana this tool belongs to.
216    fn gana(&self) -> crate::Gana;
217
218    /// Effect row — what this tool does to the world.
219    fn effects(&self) -> &crate::EffectRow;
220
221    /// Execute the tool.
222    async fn call(&self, ctx: &mut crate::Context, args: Args) -> crate::Result<Output>;
223
224    /// Access this tool's statistics.
225    fn stats(&self) -> &ToolStats;
226
227    /// Human-readable description.
228    fn description(&self) -> &str {
229        self.gana().description()
230    }
231
232    /// JSON-Schema-style description of the accepted arguments.
233    ///
234    /// Defaults to an empty object (no schema). Curated tools override this
235    /// so `tools.list` can show clients the argument contract.
236    fn input_schema(&self) -> serde_json::Value {
237        serde_json::json!({})
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn stats_record_success() {
247        let stats = ToolStats::default();
248        stats.record_success(Duration::from_millis(5), Duration::from_millis(3));
249        assert_eq!(stats.call_count.load(Ordering::Relaxed), 1);
250        assert_eq!(stats.success_count.load(Ordering::Relaxed), 1);
251        assert_eq!(stats.success_rate(), 1.0);
252    }
253
254    #[test]
255    fn stats_record_failure() {
256        let stats = ToolStats::default();
257        stats.record_success(Duration::from_millis(5), Duration::from_millis(3));
258        stats.record_failure(Duration::from_millis(2));
259        assert_eq!(stats.call_count.load(Ordering::Relaxed), 2);
260        assert_eq!(stats.success_count.load(Ordering::Relaxed), 1);
261        assert_eq!(stats.success_rate(), 0.5);
262    }
263
264    #[test]
265    fn stats_should_retire() {
266        let stats = ToolStats::default();
267        // 2 successes + 13 failures = 0.125 effectiveness (< 0.2 threshold)
268        for _ in 0..2 {
269            stats.record_success(Duration::from_millis(1), Duration::from_millis(1));
270        }
271        for _ in 0..13 {
272            stats.record_failure(Duration::from_millis(1));
273        }
274        assert!(stats.should_retire(10, 0.2));
275    }
276
277    #[test]
278    fn stats_is_hot() {
279        let stats = ToolStats::default();
280        for _ in 0..1001 {
281            stats.record_success(Duration::from_millis(1), Duration::from_millis(1));
282        }
283        assert!(stats.is_hot(1000));
284    }
285
286    #[test]
287    fn stats_snapshot_restore_roundtrip() {
288        let stats = ToolStats::default();
289        stats.record_success(Duration::from_millis(5), Duration::from_millis(3));
290        stats.record_failure(Duration::from_millis(2));
291        let snap = stats.snapshot();
292
293        let restored = ToolStats::default();
294        restored.restore(&snap);
295        assert_eq!(restored.call_count.load(Ordering::Relaxed), 2);
296        assert_eq!(restored.success_count.load(Ordering::Relaxed), 1);
297        assert_eq!(
298            restored.peak_latency_ns.load(Ordering::Relaxed),
299            snap.peak_latency_ns
300        );
301        assert!((restored.effectiveness_f32() - 0.5).abs() < f32::EPSILON);
302    }
303
304    #[test]
305    fn stats_track_peak_latency() {
306        // Regression: the peak (formerly mislabeled "p99") field was never
307        // updated, so the high-latency anomaly path could never fire.
308        let stats = ToolStats::default();
309        stats.record_success(Duration::from_millis(10), Duration::from_millis(1));
310        assert_eq!(stats.peak_latency_ns.load(Ordering::Relaxed), 10_000_000);
311        stats.record_failure(Duration::from_millis(25));
312        assert_eq!(stats.peak_latency_ns.load(Ordering::Relaxed), 25_000_000);
313        stats.record_success(Duration::from_millis(5), Duration::from_millis(1));
314        assert_eq!(
315            stats.peak_latency_ns.load(Ordering::Relaxed),
316            25_000_000,
317            "peak latency must never decrease"
318        );
319    }
320}