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                })
117                .collect(),
118        }
119    }
120
121    pub fn to_triggers(&self) -> Vec<Trigger> {
122        self.triggers
123            .iter()
124            .map(|t| Trigger {
125                kind: t.kind,
126                pattern: t.pattern.clone(),
127            })
128            .collect()
129    }
130
131    pub fn to_requirements(&self) -> Vec<Requirement> {
132        self.requires
133            .iter()
134            .map(|(name, version)| Requirement {
135                name: name.clone(),
136                version: version.clone(),
137            })
138            .collect()
139    }
140
141    pub fn to_mcp_requirements(&self) -> Vec<McpRequirement> {
142        self.mcp
143            .iter()
144            .map(|g| McpRequirement {
145                tool_pattern: g.tool_pattern.clone(),
146                capability: g.capability,
147                fallback: String::new(),
148            })
149            .collect()
150    }
151}
152
153#[derive(Debug, Clone, Default, Serialize)]
154pub struct GeneDiff {
155    pub triggers_added: Vec<TriggerGene>,
156    pub triggers_removed: Vec<TriggerGene>,
157    pub steps_added: Vec<StepGene>,
158    pub steps_removed: Vec<StepGene>,
159    /// Same intent, different description or tool. (old, new).
160    pub steps_changed: Vec<(StepGene, StepGene)>,
161    /// (name, old_version, new_version).
162    pub requires_changed: Vec<(String, String, String)>,
163    pub requires_added: Vec<(String, String)>,
164    pub requires_removed: Vec<(String, String)>,
165    pub mcp_added: Vec<McpGene>,
166    pub mcp_removed: Vec<McpGene>,
167}
168
169impl GeneDiff {
170    pub fn between(a: &SkillGene, b: &SkillGene) -> Self {
171        let mut d = GeneDiff {
172            triggers_added: b.triggers.difference(&a.triggers).cloned().collect(),
173            triggers_removed: a.triggers.difference(&b.triggers).cloned().collect(),
174            mcp_added: b.mcp.difference(&a.mcp).cloned().collect(),
175            mcp_removed: a.mcp.difference(&b.mcp).cloned().collect(),
176            ..Default::default()
177        };
178
179        // Requires — key-wise
180        for (name, a_ver) in &a.requires {
181            match b.requires.get(name) {
182                None => d.requires_removed.push((name.clone(), a_ver.clone())),
183                Some(b_ver) if b_ver != a_ver => {
184                    d.requires_changed
185                        .push((name.clone(), a_ver.clone(), b_ver.clone()));
186                }
187                _ => {}
188            }
189        }
190        for (name, b_ver) in &b.requires {
191            if !a.requires.contains_key(name) {
192                d.requires_added.push((name.clone(), b_ver.clone()));
193            }
194        }
195
196        // Steps — match by intent (when both have Some(intent) and they match)
197        let a_by_intent: BTreeMap<&str, &StepGene> = a
198            .steps
199            .iter()
200            .filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
201            .collect();
202        let b_by_intent: BTreeMap<&str, &StepGene> = b
203            .steps
204            .iter()
205            .filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
206            .collect();
207
208        for (intent, a_step) in &a_by_intent {
209            match b_by_intent.get(intent) {
210                None => d.steps_removed.push((*a_step).clone()),
211                Some(b_step) if a_step != b_step => {
212                    d.steps_changed.push(((*a_step).clone(), (*b_step).clone()));
213                }
214                _ => {}
215            }
216        }
217        for (intent, b_step) in &b_by_intent {
218            if !a_by_intent.contains_key(intent) {
219                d.steps_added.push((*b_step).clone());
220            }
221        }
222
223        // Steps without intent in either side are appended to added/removed
224        // wholesale (they cannot be matched).
225        for s in a.steps.iter().filter(|s| s.intent.is_none()) {
226            d.steps_removed.push(s.clone());
227        }
228        for s in b.steps.iter().filter(|s| s.intent.is_none()) {
229            d.steps_added.push(s.clone());
230        }
231
232        d
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::skill::manifest::{Content, Procedure, ProcedureStep, Trigger};
240    use crate::skill::types::{Category, TriggerKind};
241
242    fn manifest_with_steps(steps: Vec<ProcedureStep>, triggers: Vec<Trigger>) -> SkillManifest {
243        SkillManifest {
244            name: "x".into(),
245            version: "0.1.0".into(),
246            publisher: "human:test".into(),
247            description: "t".into(),
248            category: Category::Workflow,
249            hosts: vec![],
250            content: Content {
251                r#abstract: "a".into(),
252                context: None,
253                procedure: Some(Procedure {
254                    variables: vec![],
255                    steps,
256                }),
257                command: None,
258            },
259            requires: vec![],
260            tags: vec![],
261            triggers,
262            priority: Default::default(),
263            evolution_log: vec![],
264            transfer_chain: vec![],
265            mcp_requirements: vec![],
266        }
267    }
268
269    #[test]
270    fn from_manifest_extracts_gene_fields() {
271        let m = manifest_with_steps(
272            vec![ProcedureStep {
273                description: "navigate".into(),
274                tool: Some("browser.go".into()),
275                intent: Some("open_page".into()),
276                tool_hint: None,
277            }],
278            vec![Trigger {
279                kind: TriggerKind::Command,
280                pattern: Some("/x".into()),
281            }],
282        );
283        let g = SkillGene::from_manifest(&m).unwrap();
284        assert_eq!(g.steps.len(), 1);
285        assert_eq!(g.steps[0].intent.as_deref(), Some("open_page"));
286        assert_eq!(g.triggers.len(), 1);
287    }
288
289    #[test]
290    fn from_manifest_rejects_non_procedure() {
291        let mut m = manifest_with_steps(vec![], vec![]);
292        m.content.procedure = None;
293        m.content.context = Some("ctx".into());
294        assert!(matches!(
295            SkillGene::from_manifest(&m),
296            Err(GeneError::NotProcedure)
297        ));
298    }
299
300    #[test]
301    fn diff_detects_added_and_changed_steps() {
302        let a = manifest_with_steps(
303            vec![ProcedureStep {
304                description: "old".into(),
305                tool: None,
306                intent: Some("i1".into()),
307                tool_hint: None,
308            }],
309            vec![],
310        );
311        let b = manifest_with_steps(
312            vec![
313                ProcedureStep {
314                    description: "new desc".into(),
315                    tool: None,
316                    intent: Some("i1".into()),
317                    tool_hint: None,
318                },
319                ProcedureStep {
320                    description: "added".into(),
321                    tool: None,
322                    intent: Some("i2".into()),
323                    tool_hint: None,
324                },
325            ],
326            vec![],
327        );
328        let ga = SkillGene::from_manifest(&a).unwrap();
329        let gb = SkillGene::from_manifest(&b).unwrap();
330        let d = GeneDiff::between(&ga, &gb);
331        assert_eq!(d.steps_changed.len(), 1);
332        assert_eq!(d.steps_added.len(), 1);
333        assert_eq!(d.steps_removed.len(), 0);
334    }
335
336    #[test]
337    fn diff_treats_intentless_steps_as_unmatched() {
338        let a = manifest_with_steps(
339            vec![ProcedureStep {
340                description: "no-intent".into(),
341                tool: None,
342                intent: None,
343                tool_hint: None,
344            }],
345            vec![],
346        );
347        let b = manifest_with_steps(vec![], vec![]);
348        let ga = SkillGene::from_manifest(&a).unwrap();
349        let gb = SkillGene::from_manifest(&b).unwrap();
350        let d = GeneDiff::between(&ga, &gb);
351        assert_eq!(d.steps_removed.len(), 1);
352    }
353
354    #[test]
355    fn round_trip_to_procedure_preserves_intent_and_tool() {
356        let g = SkillGene {
357            triggers: BTreeSet::new(),
358            steps: vec![StepGene {
359                intent: Some("i".into()),
360                description: "d".into(),
361                tool: Some("t".into()),
362            }],
363            requires: BTreeMap::new(),
364            mcp: BTreeSet::new(),
365        };
366        let p = g.to_procedure();
367        assert_eq!(p.steps.len(), 1);
368        assert_eq!(p.steps[0].intent.as_deref(), Some("i"));
369        assert_eq!(p.steps[0].tool.as_deref(), Some("t"));
370        assert!(p.steps[0].tool_hint.is_none());
371    }
372}