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            scope: Default::default(),
251            fleet: None,
252            project: None,
253            hosts: vec![],
254            content: Content {
255                r#abstract: "a".into(),
256                context: None,
257                procedure: Some(Procedure {
258                    variables: vec![],
259                    steps,
260                }),
261                command: None,
262                note: None,
263            },
264            requires: vec![],
265            tags: vec![],
266            triggers,
267            priority: Default::default(),
268            evolution_log: vec![],
269            transfer_chain: vec![],
270            mcp_requirements: vec![],
271            provenance: Default::default(),
272            updated_at: chrono::Utc::now(),
273        }
274    }
275
276    #[test]
277    fn from_manifest_extracts_gene_fields() {
278        let m = manifest_with_steps(
279            vec![ProcedureStep {
280                description: "navigate".into(),
281                tool: Some("browser.go".into()),
282                intent: Some("open_page".into()),
283                tool_hint: None,
284                ..Default::default()
285            }],
286            vec![Trigger {
287                kind: TriggerKind::Command,
288                pattern: Some("/x".into()),
289            }],
290        );
291        let g = SkillGene::from_manifest(&m).unwrap();
292        assert_eq!(g.steps.len(), 1);
293        assert_eq!(g.steps[0].intent.as_deref(), Some("open_page"));
294        assert_eq!(g.triggers.len(), 1);
295    }
296
297    #[test]
298    fn from_manifest_rejects_non_procedure() {
299        let mut m = manifest_with_steps(vec![], vec![]);
300        m.content.procedure = None;
301        m.content.context = Some("ctx".into());
302        assert!(matches!(
303            SkillGene::from_manifest(&m),
304            Err(GeneError::NotProcedure)
305        ));
306    }
307
308    #[test]
309    fn diff_detects_added_and_changed_steps() {
310        let a = manifest_with_steps(
311            vec![ProcedureStep {
312                description: "old".into(),
313                tool: None,
314                intent: Some("i1".into()),
315                tool_hint: None,
316                ..Default::default()
317            }],
318            vec![],
319        );
320        let b = manifest_with_steps(
321            vec![
322                ProcedureStep {
323                    description: "new desc".into(),
324                    tool: None,
325                    intent: Some("i1".into()),
326                    tool_hint: None,
327                    ..Default::default()
328                },
329                ProcedureStep {
330                    description: "added".into(),
331                    tool: None,
332                    intent: Some("i2".into()),
333                    tool_hint: None,
334                    ..Default::default()
335                },
336            ],
337            vec![],
338        );
339        let ga = SkillGene::from_manifest(&a).unwrap();
340        let gb = SkillGene::from_manifest(&b).unwrap();
341        let d = GeneDiff::between(&ga, &gb);
342        assert_eq!(d.steps_changed.len(), 1);
343        assert_eq!(d.steps_added.len(), 1);
344        assert_eq!(d.steps_removed.len(), 0);
345    }
346
347    #[test]
348    fn diff_treats_intentless_steps_as_unmatched() {
349        let a = manifest_with_steps(
350            vec![ProcedureStep {
351                description: "no-intent".into(),
352                tool: None,
353                intent: None,
354                tool_hint: None,
355                ..Default::default()
356            }],
357            vec![],
358        );
359        let b = manifest_with_steps(vec![], vec![]);
360        let ga = SkillGene::from_manifest(&a).unwrap();
361        let gb = SkillGene::from_manifest(&b).unwrap();
362        let d = GeneDiff::between(&ga, &gb);
363        assert_eq!(d.steps_removed.len(), 1);
364    }
365
366    #[test]
367    fn round_trip_to_procedure_preserves_intent_and_tool() {
368        let g = SkillGene {
369            triggers: BTreeSet::new(),
370            steps: vec![StepGene {
371                intent: Some("i".into()),
372                description: "d".into(),
373                tool: Some("t".into()),
374            }],
375            requires: BTreeMap::new(),
376            mcp: BTreeSet::new(),
377        };
378        let p = g.to_procedure();
379        assert_eq!(p.steps.len(), 1);
380        assert_eq!(p.steps[0].intent.as_deref(), Some("i"));
381        assert_eq!(p.steps[0].tool.as_deref(), Some("t"));
382        assert!(p.steps[0].tool_hint.is_none());
383    }
384}