Skip to main content

mur_common/skill/
credit.rs

1//! Credit ledger data types (M7c).
2//!
3//! `CreditEntry` is one append-only JSON line in
4//! `~/.mur/agents/<agent>/credit/ledger.jsonl`. The ledger is per-agent,
5//! never mutated in place, and read across peers by `cmd::skill_credit`.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum CreditKind {
13    Author,
14    Mutator,
15    Recombiner,
16    Propagator,
17}
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(untagged)]
21pub enum CreditEvidence {
22    Author,
23    Mutator {
24        from_version: String,
25        diff_summary: String,
26    },
27    Recombiner {
28        role: String,
29        child: String,
30    },
31    Propagator {
32        from_agent: String,
33        fitness_at_install: f64,
34        samples_at_install: u64,
35    },
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct CreditEntry {
40    pub ts: DateTime<Utc>,
41    pub skill: String,
42    pub skill_version: String,
43    pub kind: CreditKind,
44    /// The crediting subject (the contributor's agent name).
45    pub agent: String,
46    /// Free-form evidence keyed by `kind`. `null` for `Author`.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub evidence: Option<CreditEvidence>,
49    /// Mirrors `EvolutionEvent.source` (e.g., `"human:alice"`, `"agent://bob"`).
50    pub source: String,
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn round_trip_author_entry() {
59        let entry = CreditEntry {
60            ts: DateTime::parse_from_rfc3339("2026-05-27T10:21:33Z")
61                .unwrap()
62                .with_timezone(&Utc),
63            skill: "research-prices".into(),
64            skill_version: "1.0.0".into(),
65            kind: CreditKind::Author,
66            agent: "alice".into(),
67            evidence: None,
68            source: "human:alice".into(),
69        };
70        let json = serde_json::to_string(&entry).unwrap();
71        let back: CreditEntry = serde_json::from_str(&json).unwrap();
72        assert_eq!(entry, back);
73    }
74
75    #[test]
76    fn propagator_evidence_round_trips() {
77        let entry = CreditEntry {
78            ts: Utc::now(),
79            skill: "x".into(),
80            skill_version: "1.0.0".into(),
81            kind: CreditKind::Propagator,
82            agent: "bob".into(),
83            evidence: Some(CreditEvidence::Propagator {
84                from_agent: "alice".into(),
85                fitness_at_install: 0.78,
86                samples_at_install: 7,
87            }),
88            source: "agent://alice".into(),
89        };
90        let json = serde_json::to_string(&entry).unwrap();
91        let back: CreditEntry = serde_json::from_str(&json).unwrap();
92        assert_eq!(entry, back);
93    }
94
95    #[test]
96    fn unknown_kind_returns_error() {
97        let raw = r#"{"ts":"2026-05-27T10:21:33Z","skill":"x","skill_version":"1.0.0","kind":"future_kind","agent":"alice","source":"human:alice"}"#;
98        let result: Result<CreditEntry, _> = serde_json::from_str(raw);
99        assert!(
100            result.is_err(),
101            "unknown kind should fail to deserialize; reader code must filter at the line level"
102        );
103    }
104}