Skip to main content

mur_common/skill/
stats.rs

1//! Per-skill runtime statistics. **Not** signed, **not** part of the
2//! publisher manifest. Lives at `<MUR_HOME>/skills/<name>/stats.json`
3//! and is rebuildable from the JSONL trace log via
4//! `mur skill reindex-stats`.
5//!
6//! ## Security
7//!
8//! `stats.json` is host-local mutable state and is **explicitly outside
9//! the DSSE signature scope** (see §2.2 Layer 1 of the skill ecosystem
10//! design). A skill's signature covers `skill.yaml` only. Stats can be
11//! deleted or rebuilt (`mur skill reindex-stats`) without affecting
12//! trust.
13
14use anyhow::{Context, Result};
15use chrono::{DateTime, Utc};
16use fd_lock::RwLock;
17use serde::{Deserialize, Serialize};
18use std::fs::OpenOptions;
19use std::path::{Path, PathBuf};
20use tempfile::NamedTempFile;
21
22pub const STATS_SCHEMA_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy, Default)]
25#[serde(rename_all = "snake_case")]
26pub enum LifecycleState {
27    #[default]
28    Draft,
29    Emerging,
30    Stable,
31    Canonical,
32    Deprecated,
33    Archived,
34}
35
36/// Sidecar stats for an installed skill. **Not part of the signed manifest.**
37///
38/// Schema evolution policy: additive only. New fields MUST be marked
39/// `#[serde(default)]` so older `mur` builds reading newer files (and newer
40/// builds reading older files) parse cleanly without migration. Do not
41/// pre-reserve fields without a producer — empty defaults create semantic
42/// ambiguity ("never set" vs "set to empty"). Add fields when their
43/// callers exist.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SkillStats {
46    pub schema_version: u32,
47    pub skill_name: String,
48    pub skill_version: String,
49    /// SHA-256 of the manifest content at the time these stats were
50    /// (re)initialised. A mismatch on load tells us the skill was
51    /// reinstalled — see `reset_on_manifest_change()`.
52    pub manifest_digest: String,
53
54    pub lifecycle_state: LifecycleState,
55    pub lifecycle_changed_at: DateTime<Utc>,
56    pub pinned: bool,
57    #[serde(default)]
58    pub pinned_reason: String,
59
60    pub usage_count: u64,
61    pub success_count: u64,
62    pub failure_count: u64,
63
64    pub last_used_at: Option<DateTime<Utc>>,
65    pub last_success_at: Option<DateTime<Utc>>,
66    pub first_successful_use_at: Option<DateTime<Utc>>,
67
68    /// Confidence at the moment of the most recent successful use (or
69    /// most recent promotion — see `lifecycle::on_promotion`). Decay is
70    /// computed *from this anchor*, never incrementally — keeps the
71    /// value numerically stable and idempotent on read.
72    pub anchor_confidence: f64,
73
74    /// Watermark for incremental reindex — the trace timestamp that
75    /// these stats have already absorbed. `mur skill reindex-stats`
76    /// resumes from here.
77    pub rebuilt_from_trace_through: Option<DateTime<Utc>>,
78
79    /// Count of inject-time `Resolution::Unresolved` outcomes for this skill.
80    /// A spike here means the skill declares intents that no longer match the
81    /// agent's MCP inventory — doctor's `intent-resolvable` check surfaces this.
82    #[serde(default)]
83    pub resolution_misses: u64,
84
85    /// Timestamp of the most recent human curation event
86    /// (`mur.skill.curated`). `None` until a human has reviewed an
87    /// LLM-extracted skill. Opens the provenance gate (see
88    /// `lifecycle::cap_for_provenance`). `#[serde(default)]` keeps older
89    /// stats files parsing.
90    #[serde(default)]
91    pub curated_at: Option<DateTime<Utc>>,
92}
93
94impl SkillStats {
95    pub fn new(
96        skill_name: &str,
97        skill_version: &str,
98        manifest_digest: &str,
99        now: DateTime<Utc>,
100    ) -> Self {
101        Self {
102            schema_version: STATS_SCHEMA_VERSION,
103            skill_name: skill_name.to_string(),
104            skill_version: skill_version.to_string(),
105            manifest_digest: manifest_digest.to_string(),
106            lifecycle_state: LifecycleState::default(),
107            lifecycle_changed_at: now,
108            pinned: false,
109            pinned_reason: String::new(),
110            usage_count: 0,
111            success_count: 0,
112            failure_count: 0,
113            last_used_at: None,
114            last_success_at: None,
115            first_successful_use_at: None,
116            anchor_confidence: 1.0,
117            rebuilt_from_trace_through: None,
118            resolution_misses: 0,
119            curated_at: None,
120        }
121    }
122
123    pub fn path(mur_home: &Path, skill_name: &str) -> PathBuf {
124        mur_home.join("skills").join(skill_name).join("stats.json")
125    }
126
127    /// Per-agent stats path: <MUR_HOME>/agents/<agent>/skills/<name>/stats.json
128    pub fn path_agent(mur_home: &Path, agent: &str, skill_name: &str) -> PathBuf {
129        mur_home
130            .join("agents")
131            .join(agent)
132            .join("skills")
133            .join(skill_name)
134            .join("stats.json")
135    }
136
137    /// Read the sidecar, or return `None` if absent. Lock-free — fine
138    /// for read-mostly callers (doctor, info, stats). Concurrent writers
139    /// going through `merge_in_place` will not corrupt the file because
140    /// they hold the exclusive lock during the write window.
141    pub fn load(path: &Path) -> Result<Option<Self>> {
142        match std::fs::read_to_string(path) {
143            Ok(s) => {
144                let stats: Self = serde_json::from_str(&s).context("deserialise stats.json")?;
145                Ok(Some(stats))
146            }
147            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
148            Err(e) => Err(e).context("read stats.json"),
149        }
150    }
151
152    /// Read-merge-write under an exclusive `fd-lock`. `merge_fn` is
153    /// called with the loaded value (or the supplied default if none
154    /// exists) and is responsible for applying the delta. The lock
155    /// window is microseconds — counter increments only.
156    pub fn merge_in_place(
157        path: &Path,
158        default: impl FnOnce() -> Self,
159        merge_fn: impl FnOnce(&mut Self) -> Result<()>,
160    ) -> Result<()> {
161        // Lock on a sidecar lockfile, not stats.json itself — POSIX
162        // flock(2) on the data file would race with rename. Same
163        // pattern as git/index.lock.
164        let lock_path = path.with_extension("lock");
165        let parent = path.parent().context("stats path has no parent")?;
166        std::fs::create_dir_all(parent).ok();
167
168        let mut lock_file = RwLock::new(
169            OpenOptions::new()
170                .create(true)
171                .truncate(true)
172                .write(true)
173                .read(true)
174                .open(&lock_path)
175                .context("open stats lockfile")?,
176        );
177        let _guard = lock_file.write().context("acquire stats lock")?;
178
179        let mut stats = Self::load(path)?.unwrap_or_else(default);
180        merge_fn(&mut stats)?;
181
182        let tmp = NamedTempFile::new_in(parent).context("create temp file for stats")?;
183        serde_json::to_writer_pretty(&tmp, &stats).context("serialise stats")?;
184        tmp.persist(path).context("persist stats")?;
185        Ok(())
186    }
187
188    /// Returns true if the loaded stats refer to a different manifest
189    /// digest than the one currently installed. Callers (the aggregator
190    /// and reindex) should `reset()` in that case rather than carry
191    /// counters across an upgrade.
192    ///
193    /// A version bump resets `usage_count` / `success_count` /
194    /// `failure_count` but **preserves** `pinned`,
195    /// `first_successful_use_at`, and `lifecycle_state` (a Canonical
196    /// skill bumping to 1.2.0 should not regress to Draft).
197    pub fn is_stale(&self, current_digest: &str) -> bool {
198        self.manifest_digest != current_digest
199    }
200
201    /// Reset counters for a manifest change, preserving pinned state
202    /// and first-success timestamp.
203    pub fn reset_for_new_manifest(
204        &mut self,
205        new_version: &str,
206        new_digest: &str,
207        now: DateTime<Utc>,
208    ) {
209        self.skill_version = new_version.to_string();
210        self.manifest_digest = new_digest.to_string();
211        self.usage_count = 0;
212        self.success_count = 0;
213        self.failure_count = 0;
214        self.last_used_at = None;
215        self.last_success_at = None;
216        self.anchor_confidence = 1.0;
217        self.rebuilt_from_trace_through = None;
218        self.lifecycle_changed_at = now;
219        // Preserve: pinned, pinned_reason, first_successful_use_at, lifecycle_state
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::thread;
227
228    fn temp_stats_path() -> (tempfile::TempDir, PathBuf) {
229        let dir = tempfile::tempdir().unwrap();
230        let path = dir.path().join("test_skill").join("stats.json");
231        let parent = path.parent().unwrap();
232        std::fs::create_dir_all(parent).unwrap();
233        (dir, path)
234    }
235
236    fn dummy_stats(name: &str) -> SkillStats {
237        SkillStats::new(name, "1.0.0", "abc123", Utc::now())
238    }
239
240    #[test]
241    fn load_returns_none_for_missing_path() {
242        let (_dir, path) = temp_stats_path();
243        let result = SkillStats::load(&path).unwrap();
244        assert!(result.is_none());
245    }
246
247    #[test]
248    fn load_returns_stats_for_valid_file() {
249        let (_dir, path) = temp_stats_path();
250        let stats = dummy_stats("test-skill");
251        std::fs::write(&path, serde_json::to_string_pretty(&stats).unwrap()).unwrap();
252        let loaded = SkillStats::load(&path).unwrap().unwrap();
253        assert_eq!(loaded.skill_name, "test-skill");
254        assert_eq!(loaded.usage_count, 0);
255    }
256
257    #[test]
258    fn merge_in_place_counter_increment() {
259        let (_dir, path) = temp_stats_path();
260        let skill_name = "merge-test".to_string();
261        let default = || dummy_stats(&skill_name);
262
263        // First merge: increment usage
264        SkillStats::merge_in_place(&path, default, |s| {
265            s.usage_count += 1;
266            Ok(())
267        })
268        .unwrap();
269
270        let loaded = SkillStats::load(&path).unwrap().unwrap();
271        assert_eq!(loaded.usage_count, 1);
272
273        // Second merge: increment again
274        SkillStats::merge_in_place(
275            &path,
276            || panic!("default should not be called"),
277            |s| {
278                s.usage_count += 2;
279                Ok(())
280            },
281        )
282        .unwrap();
283
284        let loaded = SkillStats::load(&path).unwrap().unwrap();
285        assert_eq!(loaded.usage_count, 3);
286    }
287
288    #[test]
289    fn concurrent_merge_both_increments_commit() {
290        let (_dir, path) = temp_stats_path();
291        let skill_name = "concurrent-test".to_string();
292        let path = std::path::PathBuf::from(path); // decouple from tempdir lifetime
293        let path2 = path.clone();
294
295        // Init the file
296        SkillStats::merge_in_place(&path, || dummy_stats(&skill_name), |_| Ok(())).unwrap();
297
298        let t1 = thread::spawn(move || {
299            SkillStats::merge_in_place(
300                &path,
301                || panic!("default should not be called"),
302                |s| {
303                    s.usage_count += 1;
304                    Ok(())
305                },
306            )
307            .unwrap();
308        });
309        let t2 = thread::spawn(move || {
310            SkillStats::merge_in_place(
311                &path2,
312                || panic!("default should not be called"),
313                |s| {
314                    s.usage_count += 2;
315                    Ok(())
316                },
317            )
318            .unwrap();
319        });
320
321        t1.join().unwrap();
322        t2.join().unwrap();
323
324        let loaded = SkillStats::load(&_dir.path().join("test_skill").join("stats.json"))
325            .unwrap()
326            .unwrap();
327        // Both increments should have committed (commutative counters)
328        assert_eq!(loaded.usage_count, 3);
329    }
330
331    #[test]
332    fn is_stale_detects_digest_mismatch() {
333        let stats = dummy_stats("test");
334        assert!(!stats.is_stale("abc123"));
335        assert!(stats.is_stale("different"));
336    }
337
338    #[test]
339    fn schema_version_1_deserialises_fixture() {
340        let fixture = r#"{
341            "schema_version": 1,
342            "skill_name": "research-patterns",
343            "skill_version": "2.3.0",
344            "manifest_digest": "abcdef",
345            "lifecycle_state": "emerging",
346            "lifecycle_changed_at": "2026-05-25T00:00:00Z",
347            "pinned": false,
348            "pinned_reason": "",
349            "usage_count": 42,
350            "success_count": 38,
351            "failure_count": 4,
352            "last_used_at": "2026-05-25T12:00:00Z",
353            "last_success_at": "2026-05-25T11:00:00Z",
354            "first_successful_use_at": "2026-05-01T00:00:00Z",
355            "anchor_confidence": 0.95,
356            "rebuilt_from_trace_through": "2026-05-25T10:00:00Z"
357        }"#;
358        let stats: SkillStats = serde_json::from_str(fixture).unwrap();
359        assert_eq!(stats.schema_version, 1);
360        assert_eq!(stats.lifecycle_state, LifecycleState::Emerging);
361        assert_eq!(stats.usage_count, 42);
362        assert_eq!(stats.anchor_confidence, 0.95);
363        assert!(stats.last_used_at.is_some());
364    }
365
366    #[test]
367    fn reset_for_new_manifest_preserves_pinned_and_state() {
368        let mut stats = SkillStats {
369            pinned: true,
370            pinned_reason: "critical".into(),
371            lifecycle_state: LifecycleState::Canonical,
372            first_successful_use_at: Some(Utc::now()),
373            usage_count: 100,
374            success_count: 95,
375            failure_count: 5,
376            ..dummy_stats("test")
377        };
378        stats.reset_for_new_manifest("2.0.0", "newdigest", Utc::now());
379        assert_eq!(stats.skill_version, "2.0.0");
380        assert_eq!(stats.usage_count, 0);
381        assert!(stats.pinned);
382        assert_eq!(stats.lifecycle_state, LifecycleState::Canonical);
383        assert!(stats.first_successful_use_at.is_some());
384    }
385
386    #[test]
387    fn curated_at_defaults_to_none_and_is_backward_compatible() {
388        // A SkillStats JSON written before this field existed must still parse.
389        let legacy = r#"{
390            "schema_version": 1, "skill_name": "x", "skill_version": "1",
391            "manifest_digest": "d", "lifecycle_state": "draft",
392            "lifecycle_changed_at": "2026-01-01T00:00:00Z", "pinned": false,
393            "usage_count": 0, "success_count": 0, "failure_count": 0,
394            "anchor_confidence": 1.0
395        }"#;
396        let s: SkillStats = serde_json::from_str(legacy).unwrap();
397        assert_eq!(s.curated_at, None);
398    }
399}