Skip to main content

mur_common/skill/
gene.rs

1//! Skill gene model (M7b).
2//!
3//! A `SkillGene` is a pure field-level projection of a `SkillManifest`. It is
4//! not persisted — derived on demand. Two genes can be diffed and recombined
5//! to produce a third manifest.
6//!
7//! Scope (M7b): procedure-mode skills only. Context-only or command-only
8//! skills are not eligible — `from_manifest` returns `Err` for them.
9
10use crate::skill::manifest::{Procedure, ProcedureStep, Requirement, SkillManifest, Trigger};
11use crate::skill::mcp::{McpRequirement, SkillCapability};
12use crate::skill::types::TriggerKind;
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, BTreeSet};
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct TriggerGene {
18    pub kind: TriggerKind,
19    pub pattern: Option<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
23pub struct McpGene {
24    pub tool_pattern: String,
25    pub capability: SkillCapability,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct StepGene {
30    /// `None` means the step is not matchable by intent in Intersection.
31    pub intent: Option<String>,
32    pub description: String,
33    pub tool: Option<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SkillGene {
38    pub triggers: BTreeSet<TriggerGene>,
39    pub steps: Vec<StepGene>,
40    /// requirement name -> semver constraint string (as written in the
41    /// manifest; parsed lazily by the Union semver merger).
42    pub requires: BTreeMap<String, String>,
43    pub mcp: BTreeSet<McpGene>,
44}
45
46#[derive(Debug, thiserror::Error)]
47pub enum GeneError {
48    #[error("skill is not procedure-mode (recombine requires procedure-mode skills)")]
49    NotProcedure,
50}
51
52impl SkillGene {
53    pub fn from_manifest(m: &SkillManifest) -> Result<Self, GeneError> {
54        let proc = m
55            .content
56            .procedure
57            .as_ref()
58            .ok_or(GeneError::NotProcedure)?;
59
60        let triggers = m
61            .triggers
62            .iter()
63            .map(|t| TriggerGene {
64                kind: t.kind,
65                pattern: t.pattern.clone(),
66            })
67            .collect();
68
69        let steps = proc
70            .steps
71            .iter()
72            .map(|s| StepGene {
73                intent: s.intent.clone(),
74                description: s.description.clone(),
75                tool: s.tool.clone(),
76            })
77            .collect();
78
79        let requires = m
80            .requires
81            .iter()
82            .map(|r| (r.name.clone(), r.version.clone()))
83            .collect();
84
85        let mcp = m
86            .mcp_requirements
87            .iter()
88            .map(|r| McpGene {
89                tool_pattern: r.tool_pattern.clone(),
90                capability: r.capability,
91            })
92            .collect();
93
94        Ok(SkillGene {
95            triggers,
96            steps,
97            requires,
98            mcp,
99        })
100    }
101
102    /// Rebuild a `Procedure` from the steps in this gene (preserves order,
103    /// no variables — Variables are copied from the keeper manifest by the
104    /// orchestrator, not the gene layer).
105    pub fn to_procedure(&self) -> Procedure {
106        Procedure {
107            variables: Vec::new(),
108            steps: self
109                .steps
110                .iter()
111                .map(|s| ProcedureStep {
112                    description: s.description.clone(),
113                    tool: s.tool.clone(),
114                    intent: s.intent.clone(),
115                    tool_hint: None,
116                    ..Default::default()
117                })
118                .collect(),
119        }
120    }
121
122    pub fn to_triggers(&self) -> Vec<Trigger> {
123        self.triggers
124            .iter()
125            .map(|t| Trigger {
126                kind: t.kind,
127                pattern: t.pattern.clone(),
128            })
129            .collect()
130    }
131
132    pub fn to_requirements(&self) -> Vec<Requirement> {
133        self.requires
134            .iter()
135            .map(|(name, version)| Requirement {
136                name: name.clone(),
137                version: version.clone(),
138            })
139            .collect()
140    }
141
142    pub fn to_mcp_requirements(&self) -> Vec<McpRequirement> {
143        self.mcp
144            .iter()
145            .map(|g| McpRequirement {
146                tool_pattern: g.tool_pattern.clone(),
147                capability: g.capability,
148                fallback: String::new(),
149            })
150            .collect()
151    }
152}
153
154#[derive(Debug, Clone, Default, Serialize)]
155pub struct GeneDiff {
156    pub triggers_added: Vec<TriggerGene>,
157    pub triggers_removed: Vec<TriggerGene>,
158    pub steps_added: Vec<StepGene>,
159    pub steps_removed: Vec<StepGene>,
160    /// Same intent, different description or tool. (old, new).
161    pub steps_changed: Vec<(StepGene, StepGene)>,
162    /// (name, old_version, new_version).
163    pub requires_changed: Vec<(String, String, String)>,
164    pub requires_added: Vec<(String, String)>,
165    pub requires_removed: Vec<(String, String)>,
166    pub mcp_added: Vec<McpGene>,
167    pub mcp_removed: Vec<McpGene>,
168}
169
170impl GeneDiff {
171    pub fn between(a: &SkillGene, b: &SkillGene) -> Self {
172        let mut d = GeneDiff {
173            triggers_added: b.triggers.difference(&a.triggers).cloned().collect(),
174            triggers_removed: a.triggers.difference(&b.triggers).cloned().collect(),
175            mcp_added: b.mcp.difference(&a.mcp).cloned().collect(),
176            mcp_removed: a.mcp.difference(&b.mcp).cloned().collect(),
177            ..Default::default()
178        };
179
180        // Requires — key-wise
181        for (name, a_ver) in &a.requires {
182            match b.requires.get(name) {
183                None => d.requires_removed.push((name.clone(), a_ver.clone())),
184                Some(b_ver) if b_ver != a_ver => {
185                    d.requires_changed
186                        .push((name.clone(), a_ver.clone(), b_ver.clone()));
187                }
188                _ => {}
189            }
190        }
191        for (name, b_ver) in &b.requires {
192            if !a.requires.contains_key(name) {
193                d.requires_added.push((name.clone(), b_ver.clone()));
194            }
195        }
196
197        // Steps — match by intent (when both have Some(intent) and they match)
198        let a_by_intent: BTreeMap<&str, &StepGene> = a
199            .steps
200            .iter()
201            .filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
202            .collect();
203        let b_by_intent: BTreeMap<&str, &StepGene> = b
204            .steps
205            .iter()
206            .filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
207            .collect();
208
209        for (intent, a_step) in &a_by_intent {
210            match b_by_intent.get(intent) {
211                None => d.steps_removed.push((*a_step).clone()),
212                Some(b_step) if a_step != b_step => {
213                    d.steps_changed.push(((*a_step).clone(), (*b_step).clone()));
214                }
215                _ => {}
216            }
217        }
218        for (intent, b_step) in &b_by_intent {
219            if !a_by_intent.contains_key(intent) {
220                d.steps_added.push((*b_step).clone());
221            }
222        }
223
224        // Steps without intent in either side are appended to added/removed
225        // wholesale (they cannot be matched).
226        for s in a.steps.iter().filter(|s| s.intent.is_none()) {
227            d.steps_removed.push(s.clone());
228        }
229        for s in b.steps.iter().filter(|s| s.intent.is_none()) {
230            d.steps_added.push(s.clone());
231        }
232
233        d
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::skill::manifest::{Content, Procedure, ProcedureStep, Trigger};
241    use crate::skill::types::{Category, TriggerKind};
242
243    fn manifest_with_steps(steps: Vec<ProcedureStep>, triggers: Vec<Trigger>) -> SkillManifest {
244        SkillManifest {
245            name: "x".into(),
246            version: "0.1.0".into(),
247            publisher: "human:test".into(),
248            description: "t".into(),
249            category: Category::Workflow,
250            hosts: vec![],
251            content: Content {
252                r#abstract: "a".into(),
253                context: None,
254                procedure: Some(Procedure {
255                    variables: vec![],
256                    steps,
257                }),
258                command: None,
259                note: None,
260            },
261            requires: vec![],
262            tags: vec![],
263            triggers,
264            priority: Default::default(),
265            evolution_log: vec![],
266            transfer_chain: vec![],
267            mcp_requirements: vec![],
268            provenance: Default::default(),
269            updated_at: chrono::Utc::now(),
270        }
271    }
272
273    #[test]
274    fn from_manifest_extracts_gene_fields() {
275        let m = manifest_with_steps(
276            vec![ProcedureStep {
277                description: "navigate".into(),
278                tool: Some("browser.go".into()),
279                intent: Some("open_page".into()),
280                tool_hint: None,
281                ..Default::default()
282            }],
283            vec![Trigger {
284                kind: TriggerKind::Command,
285                pattern: Some("/x".into()),
286            }],
287        );
288        let g = SkillGene::from_manifest(&m).unwrap();
289        assert_eq!(g.steps.len(), 1);
290        assert_eq!(g.steps[0].intent.as_deref(), Some("open_page"));
291        assert_eq!(g.triggers.len(), 1);
292    }
293
294    #[test]
295    fn from_manifest_rejects_non_procedure() {
296        let mut m = manifest_with_steps(vec![], vec![]);
297        m.content.procedure = None;
298        m.content.context = Some("ctx".into());
299        assert!(matches!(
300            SkillGene::from_manifest(&m),
301            Err(GeneError::NotProcedure)
302        ));
303    }
304
305    #[test]
306    fn diff_detects_added_and_changed_steps() {
307        let a = manifest_with_steps(
308            vec![ProcedureStep {
309                description: "old".into(),
310                tool: None,
311                intent: Some("i1".into()),
312                tool_hint: None,
313                ..Default::default()
314            }],
315            vec![],
316        );
317        let b = manifest_with_steps(
318            vec![
319                ProcedureStep {
320                    description: "new desc".into(),
321                    tool: None,
322                    intent: Some("i1".into()),
323                    tool_hint: None,
324                    ..Default::default()
325                },
326                ProcedureStep {
327                    description: "added".into(),
328                    tool: None,
329                    intent: Some("i2".into()),
330                    tool_hint: None,
331                    ..Default::default()
332                },
333            ],
334            vec![],
335        );
336        let ga = SkillGene::from_manifest(&a).unwrap();
337        let gb = SkillGene::from_manifest(&b).unwrap();
338        let d = GeneDiff::between(&ga, &gb);
339        assert_eq!(d.steps_changed.len(), 1);
340        assert_eq!(d.steps_added.len(), 1);
341        assert_eq!(d.steps_removed.len(), 0);
342    }
343
344    #[test]
345    fn diff_treats_intentless_steps_as_unmatched() {
346        let a = manifest_with_steps(
347            vec![ProcedureStep {
348                description: "no-intent".into(),
349                tool: None,
350                intent: None,
351                tool_hint: None,
352                ..Default::default()
353            }],
354            vec![],
355        );
356        let b = manifest_with_steps(vec![], vec![]);
357        let ga = SkillGene::from_manifest(&a).unwrap();
358        let gb = SkillGene::from_manifest(&b).unwrap();
359        let d = GeneDiff::between(&ga, &gb);
360        assert_eq!(d.steps_removed.len(), 1);
361    }
362
363    #[test]
364    fn round_trip_to_procedure_preserves_intent_and_tool() {
365        let g = SkillGene {
366            triggers: BTreeSet::new(),
367            steps: vec![StepGene {
368                intent: Some("i".into()),
369                description: "d".into(),
370                tool: Some("t".into()),
371            }],
372            requires: BTreeMap::new(),
373            mcp: BTreeSet::new(),
374        };
375        let p = g.to_procedure();
376        assert_eq!(p.steps.len(), 1);
377        assert_eq!(p.steps[0].intent.as_deref(), Some("i"));
378        assert_eq!(p.steps[0].tool.as_deref(), Some("t"));
379        assert!(p.steps[0].tool_hint.is_none());
380    }
381}