Skip to main content

mur_common/
pattern.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::borrow::Cow;
4use std::collections::HashMap;
5
6use crate::knowledge::KnowledgeBase;
7
8/// Pattern schema version
9pub const SCHEMA_VERSION: u32 = 3;
10
11/// The kind of knowledge a pattern represents.
12#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "lowercase")]
14pub enum PatternKind {
15    /// Technical knowledge (code patterns, architecture, tools)
16    #[default]
17    Technical,
18    /// User preference (language, style, format)
19    Preference,
20    /// Factual knowledge (server addresses, config values)
21    Fact,
22    /// Procedural knowledge (how-to steps, workflows)
23    Procedure,
24    /// Behavioral rules (do/don't rules for interaction)
25    Behavioral,
26}
27
28/// How a pattern's knowledge was originally captured.
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
30#[serde(rename_all = "snake_case")]
31pub enum OriginTrigger {
32    /// User explicitly said "remember this"
33    UserExplicit,
34    /// User corrected the AI's behavior
35    UserCorrection,
36    /// Agent inferred from behavior patterns
37    AgentInferred,
38    /// Shared from community
39    CommunityShared,
40    /// Auto-consolidated during memory consolidation
41    AutoConsolidated,
42    /// Automatically generated (e.g. starter patterns)
43    Automatic,
44}
45
46/// Provenance metadata — where and how a pattern was learned.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Origin {
49    /// Which tool created this pattern (e.g. "commander", "claude-code")
50    pub source: String,
51    /// How the knowledge was captured
52    pub trigger: OriginTrigger,
53
54    /// Who/what produced this origin event — preferred successor to
55    /// `user`/`platform`. Optional for backward compat with pre-sync YAML.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub actor: Option<crate::Actor>,
58
59    /// Legacy free-form user identifier. Prefer [`Self::actor`].
60    #[deprecated(note = "use actor instead, removed in v2.3")]
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub user: Option<String>,
63
64    /// Legacy free-form platform identifier. Prefer [`Self::actor`].
65    #[deprecated(note = "use actor.source instead, removed in v2.3")]
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub platform: Option<String>,
68
69    /// Extraction confidence (0.0-1.0) — how sure the tool was about the extraction
70    #[serde(default = "default_origin_confidence")]
71    pub confidence: f64,
72}
73
74fn default_origin_confidence() -> f64 {
75    1.0
76}
77
78/// A MUR pattern — the atomic unit of learned knowledge.
79///
80/// YAML files in `~/.mur/patterns/` are the source of truth.
81/// LanceDB indexes are always rebuildable from these.
82///
83/// KnowledgeBase fields are flattened so existing YAML stays compatible.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Pattern {
86    /// Shared knowledge fields (flattened into YAML)
87    #[serde(flatten)]
88    pub base: KnowledgeBase,
89
90    /// The kind of knowledge this pattern represents.
91    /// None is treated as Technical for backward compatibility.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub kind: Option<PatternKind>,
94
95    /// Provenance metadata — where and how this pattern was learned.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub origin: Option<Origin>,
98
99    /// Attached diagrams, images, etc.
100    #[serde(default)]
101    pub attachments: Vec<Attachment>,
102}
103
104impl Pattern {
105    /// Get the effective kind, defaulting to Technical if not set.
106    pub fn effective_kind(&self) -> PatternKind {
107        self.kind.unwrap_or(PatternKind::Technical)
108    }
109}
110
111// Allow `pattern.name`, `pattern.content`, etc. via auto-deref.
112impl std::ops::Deref for Pattern {
113    type Target = KnowledgeBase;
114    fn deref(&self) -> &KnowledgeBase {
115        &self.base
116    }
117}
118impl std::ops::DerefMut for Pattern {
119    fn deref_mut(&mut self) -> &mut KnowledgeBase {
120        &mut self.base
121    }
122}
123
124/// An attachment to a pattern (diagram, image, etc.)
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Attachment {
127    /// Type of attachment
128    #[serde(rename = "type")]
129    pub att_type: AttachmentType,
130    /// Format of the attachment
131    pub format: AttachmentFormat,
132    /// Path to the attachment file (relative to ~/.mur/)
133    pub path: String,
134    /// Human-readable description
135    #[serde(default)]
136    pub description: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140#[serde(rename_all = "lowercase")]
141pub enum AttachmentType {
142    Diagram,
143    Image,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147#[serde(rename_all = "lowercase")]
148pub enum AttachmentFormat {
149    Mermaid,
150    #[serde(rename = "plantuml")]
151    PlantUml,
152    Png,
153    Svg,
154}
155
156impl AttachmentFormat {
157    /// Whether this format is text-based (can be inlined into prompts).
158    pub fn is_text_based(&self) -> bool {
159        matches!(self, AttachmentFormat::Mermaid | AttachmentFormat::PlantUml)
160    }
161
162    /// Detect format from file extension.
163    pub fn from_extension(ext: &str) -> Option<Self> {
164        match ext.to_lowercase().as_str() {
165            "mmd" | "mermaid" => Some(AttachmentFormat::Mermaid),
166            "puml" | "plantuml" => Some(AttachmentFormat::PlantUml),
167            "png" => Some(AttachmentFormat::Png),
168            "svg" => Some(AttachmentFormat::Svg),
169            _ => None,
170        }
171    }
172
173    /// The markdown code fence language tag for text-based formats.
174    pub fn fence_lang(&self) -> &str {
175        match self {
176            AttachmentFormat::Mermaid => "mermaid",
177            AttachmentFormat::PlantUml => "plantuml",
178            _ => "",
179        }
180    }
181}
182
183impl AttachmentType {
184    /// Infer attachment type from format.
185    pub fn from_format(format: &AttachmentFormat) -> Self {
186        match format {
187            AttachmentFormat::Mermaid | AttachmentFormat::PlantUml => AttachmentType::Diagram,
188            AttachmentFormat::Png | AttachmentFormat::Svg => AttachmentType::Image,
189        }
190    }
191}
192
193/// Dual-layer content inspired by LanceDB Pro Plugin Rule 6.
194/// Max 500 chars per layer.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(untagged)]
197pub enum Content {
198    /// v2: dual-layer
199    DualLayer {
200        technical: String,
201        #[serde(default)]
202        principle: Option<String>,
203    },
204    /// v1 compat: single string
205    Plain(String),
206}
207
208impl Default for Content {
209    fn default() -> Self {
210        Content::Plain(String::new())
211    }
212}
213
214impl Content {
215    /// Get the full content as a single string (for embedding).
216    ///
217    /// Returns `Cow::Borrowed` for `Plain` and `DualLayer` without principle,
218    /// avoiding allocation in the common case.
219    pub fn as_text(&self) -> Cow<'_, str> {
220        match self {
221            Content::DualLayer {
222                technical,
223                principle,
224            } => match principle {
225                Some(p) => Cow::Owned(format!("{}\n\n{}", technical, p)),
226                None => Cow::Borrowed(technical),
227            },
228            Content::Plain(s) => Cow::Borrowed(s),
229        }
230    }
231
232    /// Max chars per content layer
233    pub const MAX_LAYER_CHARS: usize = 500;
234}
235
236#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
237#[serde(rename_all = "lowercase")]
238pub enum Tier {
239    /// Short-lived, from a single session. Decay: 14 days half-life.
240    #[default]
241    Session,
242    /// Validated project convention. Decay: 90 days half-life.
243    Project,
244    /// Cross-project core preference. Decay: 365 days half-life.
245    Core,
246}
247
248impl Tier {
249    /// Half-life in days for decay calculation
250    pub fn decay_half_life_days(&self) -> u32 {
251        match self {
252            Tier::Session => 14,
253            Tier::Project => 90,
254            Tier::Core => 365,
255        }
256    }
257}
258
259#[derive(Debug, Clone, Default, Serialize, Deserialize)]
260pub struct Tags {
261    #[serde(default)]
262    pub languages: Vec<String>,
263    #[serde(default)]
264    pub topics: Vec<String>,
265    /// Extra user-defined tags
266    #[serde(flatten)]
267    pub extra: HashMap<String, Vec<String>>,
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
271pub struct Applies {
272    /// Project names or ["*"] for universal
273    #[serde(default)]
274    pub projects: Vec<String>,
275    #[serde(default)]
276    pub languages: Vec<String>,
277    /// Only inject when using these tools (e.g. "claude-code")
278    #[serde(default)]
279    pub tools: Vec<String>,
280    /// Auto-detect scope from pwd/git remote
281    #[serde(default)]
282    pub auto_scope: bool,
283}
284
285/// Per-actor contribution to a pattern's Evidence.
286///
287/// Stored in `Evidence.contributions` keyed by [`crate::Actor::key`].
288/// Allows effectiveness to be computed per-actor for Team pattern leaderboards
289/// or personalized retrieval in future Phase 2 work.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291pub struct Contribution {
292    #[serde(default)]
293    pub success_signals: u64,
294    #[serde(default)]
295    pub override_signals: u64,
296    pub last_seen: DateTime<Utc>,
297}
298
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct Evidence {
301    #[serde(default)]
302    pub source_sessions: Vec<String>,
303    pub first_seen: Option<DateTime<Utc>>,
304    pub last_validated: Option<DateTime<Utc>>,
305    #[serde(default)]
306    pub injection_count: u64,
307    #[serde(default)]
308    pub success_signals: u64,
309    #[serde(default)]
310    pub failure_signals: u64,
311    #[serde(default)]
312    pub override_signals: u64,
313    /// Per-actor signal counts, keyed by `Actor::key()` (e.g. `"Slack:U123ABC"`).
314    /// Empty for patterns that have never been touched by the sync protocol.
315    #[serde(default)]
316    pub contributions: HashMap<String, Contribution>,
317}
318
319impl Evidence {
320    /// Effectiveness ratio: success / (success + override)
321    pub fn effectiveness(&self) -> f64 {
322        let total = self.success_signals + self.override_signals;
323        if total == 0 {
324            0.5 // neutral prior
325        } else {
326            self.success_signals as f64 / total as f64
327        }
328    }
329
330    /// Per-actor effectiveness ratio computed from `contributions`.
331    ///
332    /// If actor has contributed to this pattern, returns their local
333    /// `success / (success + override)` ratio. If unknown, returns
334    /// neutral prior of 0.5.
335    pub fn effectiveness_by_actor(&self, actor: &crate::Actor) -> f64 {
336        match self.contributions.get(&actor.key()) {
337            Some(c) => {
338                let total = c.success_signals + c.override_signals;
339                if total == 0 {
340                    0.5 // neutral prior if actor present but no signals yet
341                } else {
342                    c.success_signals as f64 / total as f64
343                }
344            }
345            None => 0.5, // neutral prior
346        }
347    }
348}
349
350#[derive(Debug, Clone, Default, Serialize, Deserialize)]
351pub struct Links {
352    /// Related patterns (bidirectional)
353    #[serde(default)]
354    pub related: Vec<String>,
355    /// Patterns this one replaces
356    #[serde(default)]
357    pub supersedes: Vec<String>,
358    /// MUR Commander workflow references (future)
359    #[serde(default)]
360    pub workflows: Vec<String>,
361}
362
363#[derive(Debug, Clone, Default, Serialize, Deserialize)]
364pub struct Lifecycle {
365    #[serde(default)]
366    pub status: LifecycleStatus,
367    /// Custom decay half-life override (days). If None, uses Tier default.
368    pub decay_half_life: Option<u32>,
369    pub last_injected: Option<DateTime<Utc>>,
370    /// Pinned by user — never auto-deprecated
371    #[serde(default)]
372    pub pinned: bool,
373    /// Muted by user — skip injection but don't delete
374    #[serde(default)]
375    pub muted: bool,
376}
377
378#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
379#[serde(rename_all = "lowercase")]
380pub enum LifecycleStatus {
381    #[default]
382    Active,
383    Deprecated,
384    Archived,
385}
386
387pub fn default_schema() -> u32 {
388    SCHEMA_VERSION
389}
390pub fn default_importance() -> f64 {
391    0.5
392}
393pub fn default_confidence() -> f64 {
394    0.5
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_attachment_format_is_text_based() {
403        assert!(AttachmentFormat::Mermaid.is_text_based());
404        assert!(AttachmentFormat::PlantUml.is_text_based());
405        assert!(!AttachmentFormat::Png.is_text_based());
406        assert!(!AttachmentFormat::Svg.is_text_based());
407    }
408
409    #[test]
410    fn test_attachment_format_from_extension() {
411        assert_eq!(
412            AttachmentFormat::from_extension("mmd"),
413            Some(AttachmentFormat::Mermaid)
414        );
415        assert_eq!(
416            AttachmentFormat::from_extension("mermaid"),
417            Some(AttachmentFormat::Mermaid)
418        );
419        assert_eq!(
420            AttachmentFormat::from_extension("puml"),
421            Some(AttachmentFormat::PlantUml)
422        );
423        assert_eq!(
424            AttachmentFormat::from_extension("plantuml"),
425            Some(AttachmentFormat::PlantUml)
426        );
427        assert_eq!(
428            AttachmentFormat::from_extension("png"),
429            Some(AttachmentFormat::Png)
430        );
431        assert_eq!(
432            AttachmentFormat::from_extension("svg"),
433            Some(AttachmentFormat::Svg)
434        );
435        assert_eq!(AttachmentFormat::from_extension("jpg"), None);
436        assert_eq!(AttachmentFormat::from_extension(""), None);
437        // Case insensitive
438        assert_eq!(
439            AttachmentFormat::from_extension("MMD"),
440            Some(AttachmentFormat::Mermaid)
441        );
442    }
443
444    #[test]
445    fn test_attachment_format_fence_lang() {
446        assert_eq!(AttachmentFormat::Mermaid.fence_lang(), "mermaid");
447        assert_eq!(AttachmentFormat::PlantUml.fence_lang(), "plantuml");
448        assert_eq!(AttachmentFormat::Png.fence_lang(), "");
449    }
450
451    #[test]
452    fn test_attachment_type_from_format() {
453        assert_eq!(
454            AttachmentType::from_format(&AttachmentFormat::Mermaid),
455            AttachmentType::Diagram
456        );
457        assert_eq!(
458            AttachmentType::from_format(&AttachmentFormat::PlantUml),
459            AttachmentType::Diagram
460        );
461        assert_eq!(
462            AttachmentType::from_format(&AttachmentFormat::Png),
463            AttachmentType::Image
464        );
465        assert_eq!(
466            AttachmentType::from_format(&AttachmentFormat::Svg),
467            AttachmentType::Image
468        );
469    }
470
471    #[test]
472    fn test_attachment_serde() {
473        let att = Attachment {
474            att_type: AttachmentType::Diagram,
475            format: AttachmentFormat::Mermaid,
476            path: "my-pattern/arch.mermaid".to_string(),
477            description: "Architecture diagram".to_string(),
478        };
479
480        let yaml = serde_yaml::to_string(&att).unwrap();
481        assert!(yaml.contains("type: diagram"));
482        assert!(yaml.contains("format: mermaid"));
483        assert!(yaml.contains("path: my-pattern/arch.mermaid"));
484        assert!(yaml.contains("description: Architecture diagram"));
485
486        let deserialized: Attachment = serde_yaml::from_str(&yaml).unwrap();
487        assert_eq!(deserialized.att_type, AttachmentType::Diagram);
488        assert_eq!(deserialized.format, AttachmentFormat::Mermaid);
489    }
490
491    #[test]
492    fn test_attachment_svg_serde() {
493        let att = Attachment {
494            att_type: AttachmentType::Image,
495            format: AttachmentFormat::Svg,
496            path: "my-pattern/logo.svg".to_string(),
497            description: "Logo".to_string(),
498        };
499
500        let yaml = serde_yaml::to_string(&att).unwrap();
501        let deserialized: Attachment = serde_yaml::from_str(&yaml).unwrap();
502        assert_eq!(deserialized.format, AttachmentFormat::Svg);
503        assert_eq!(deserialized.att_type, AttachmentType::Image);
504    }
505
506    #[test]
507    fn test_pattern_kind_serde_roundtrip() {
508        // All variants serialize to lowercase
509        let cases = vec![
510            (PatternKind::Technical, "technical"),
511            (PatternKind::Preference, "preference"),
512            (PatternKind::Fact, "fact"),
513            (PatternKind::Procedure, "procedure"),
514            (PatternKind::Behavioral, "behavioral"),
515        ];
516        for (kind, expected_str) in cases {
517            let yaml = serde_yaml::to_string(&kind).unwrap();
518            assert!(
519                yaml.contains(expected_str),
520                "Expected '{}' in '{}'",
521                expected_str,
522                yaml
523            );
524            let deserialized: PatternKind = serde_yaml::from_str(&yaml).unwrap();
525            assert_eq!(deserialized, kind);
526        }
527    }
528
529    #[test]
530    fn test_origin_trigger_serde_roundtrip() {
531        let cases = vec![
532            (OriginTrigger::UserExplicit, "user_explicit"),
533            (OriginTrigger::UserCorrection, "user_correction"),
534            (OriginTrigger::AgentInferred, "agent_inferred"),
535            (OriginTrigger::CommunityShared, "community_shared"),
536            (OriginTrigger::AutoConsolidated, "auto_consolidated"),
537        ];
538        for (trigger, expected_str) in cases {
539            let yaml = serde_yaml::to_string(&trigger).unwrap();
540            assert!(yaml.contains(expected_str));
541            let deserialized: OriginTrigger = serde_yaml::from_str(&yaml).unwrap();
542            assert_eq!(deserialized, trigger);
543        }
544    }
545
546    #[test]
547    fn test_origin_serde_roundtrip() {
548        #[allow(deprecated)] // intentional: testing legacy field serialization
549        let origin = Origin {
550            source: "commander".to_string(),
551            trigger: OriginTrigger::UserExplicit,
552            actor: None,
553            user: Some("david".to_string()),
554            platform: Some("slack".to_string()),
555            confidence: 0.95,
556        };
557        let yaml = serde_yaml::to_string(&origin).unwrap();
558        assert!(yaml.contains("source: commander"));
559        assert!(yaml.contains("trigger: user_explicit"));
560        assert!(yaml.contains("user: david"));
561        assert!(yaml.contains("platform: slack"));
562
563        let deserialized: Origin = serde_yaml::from_str(&yaml).unwrap();
564        assert_eq!(deserialized.source, "commander");
565        assert_eq!(deserialized.trigger, OriginTrigger::UserExplicit);
566        #[allow(deprecated)]
567        {
568            assert_eq!(deserialized.user, Some("david".to_string()));
569            assert_eq!(deserialized.platform, Some("slack".to_string()));
570        }
571        assert!((deserialized.confidence - 0.95).abs() < 0.001);
572    }
573
574    #[test]
575    fn test_origin_optional_fields_omitted() {
576        #[allow(deprecated)] // intentional: testing legacy field omission
577        let origin = Origin {
578            source: "cli".to_string(),
579            trigger: OriginTrigger::AgentInferred,
580            actor: None,
581            user: None,
582            platform: None,
583            confidence: 1.0,
584        };
585        let yaml = serde_yaml::to_string(&origin).unwrap();
586        assert!(!yaml.contains("user:"));
587        assert!(!yaml.contains("platform:"));
588    }
589
590    #[test]
591    fn test_pattern_with_kind_and_origin_roundtrip() {
592        use crate::knowledge::KnowledgeBase;
593        let pattern = Pattern {
594            base: KnowledgeBase {
595                name: "test-pref".into(),
596                description: "A preference".into(),
597                content: Content::Plain("Use Chinese".into()),
598                ..Default::default()
599            },
600            kind: Some(PatternKind::Preference),
601            #[allow(deprecated)] // intentional: testing legacy field in pattern roundtrip
602            origin: Some(Origin {
603                source: "commander".into(),
604                trigger: OriginTrigger::UserExplicit,
605                actor: None,
606                user: Some("david".into()),
607                platform: None,
608                confidence: 0.9,
609            }),
610            attachments: vec![],
611        };
612
613        let yaml = serde_yaml::to_string(&pattern).unwrap();
614        assert!(yaml.contains("kind: preference"));
615        assert!(yaml.contains("source: commander"));
616
617        let deserialized: Pattern = serde_yaml::from_str(&yaml).unwrap();
618        assert_eq!(deserialized.kind, Some(PatternKind::Preference));
619        assert_eq!(deserialized.effective_kind(), PatternKind::Preference);
620        assert!(deserialized.origin.is_some());
621        assert_eq!(deserialized.origin.unwrap().source, "commander");
622    }
623
624    #[test]
625    fn origin_with_actor_roundtrip() {
626        use crate::{Actor, ActorSource};
627        #[allow(deprecated)]
628        let o = Origin {
629            source: "commander".into(),
630            trigger: OriginTrigger::AgentInferred,
631            actor: Some(Actor {
632                source: ActorSource::Slack,
633                native_id: "U999".into(),
634                display_name: Some("bob".into()),
635                resolved_user_id: None,
636            }),
637            user: None,
638            platform: None,
639            confidence: 0.8,
640        };
641        let y = serde_yaml::to_string(&o).unwrap();
642        let back: Origin = serde_yaml::from_str(&y).unwrap();
643        assert_eq!(back.actor.as_ref().unwrap().native_id, "U999");
644    }
645
646    #[test]
647    fn origin_backward_compat_no_actor_field() {
648        // 舊 YAML (pre-sync feature) lacks `actor`, `user`, `platform` fields
649        let old_yaml = r#"
650source: starter
651trigger: automatic
652confidence: 0.5
653"#;
654        let o: Origin = serde_yaml::from_str(old_yaml).unwrap();
655        assert!(o.actor.is_none());
656        #[allow(deprecated)]
657        {
658            assert!(o.user.is_none());
659            assert!(o.platform.is_none());
660        }
661    }
662
663    #[test]
664    fn origin_reads_legacy_user_platform_yaml() {
665        // YAML written by pre-sync code that populated user/platform (not actor)
666        let legacy_yaml = r#"
667source: import
668trigger: automatic
669user: alice
670platform: "CLAUDE.md"
671confidence: 0.7
672"#;
673        let o: Origin = serde_yaml::from_str(legacy_yaml).unwrap();
674        #[allow(deprecated)]
675        {
676            assert_eq!(o.user.as_deref(), Some("alice"));
677            assert_eq!(o.platform.as_deref(), Some("CLAUDE.md"));
678        }
679        assert!(o.actor.is_none());
680    }
681
682    #[test]
683    fn test_pattern_backward_compat_no_kind_no_origin() {
684        // Existing YAML without kind/origin fields should deserialize fine
685        let yaml = "name: old-pattern\ndescription: Old\ncontent: Some content\n";
686        let pattern: Pattern = serde_yaml::from_str(yaml).unwrap();
687        assert_eq!(pattern.name, "old-pattern");
688        assert!(pattern.kind.is_none());
689        assert!(pattern.origin.is_none());
690        assert_eq!(pattern.effective_kind(), PatternKind::Technical);
691    }
692
693    #[test]
694    fn test_pattern_kind_default() {
695        assert_eq!(PatternKind::default(), PatternKind::Technical);
696    }
697
698    #[test]
699    fn evidence_contributions_default_empty() {
700        let e = Evidence::default();
701        assert!(e.contributions.is_empty());
702    }
703
704    #[test]
705    fn evidence_effectiveness_by_actor_splits_signals() {
706        use crate::{Actor, ActorSource};
707
708        let alice = Actor {
709            source: ActorSource::Slack,
710            native_id: "alice".into(),
711            display_name: None,
712            resolved_user_id: None,
713        };
714        let bob = Actor {
715            source: ActorSource::Slack,
716            native_id: "bob".into(),
717            display_name: None,
718            resolved_user_id: None,
719        };
720
721        let mut contribs = HashMap::new();
722        contribs.insert(
723            alice.key(),
724            Contribution {
725                success_signals: 8,
726                override_signals: 2,
727                last_seen: Utc::now(),
728            },
729        );
730        contribs.insert(
731            bob.key(),
732            Contribution {
733                success_signals: 1,
734                override_signals: 4,
735                last_seen: Utc::now(),
736            },
737        );
738
739        let e = Evidence {
740            source_sessions: vec![],
741            first_seen: None,
742            last_validated: None,
743            injection_count: 15,
744            success_signals: 9,
745            failure_signals: 0,
746            override_signals: 6,
747            contributions: contribs,
748        };
749
750        assert!((e.effectiveness_by_actor(&alice) - 0.8).abs() < 0.001);
751        assert!((e.effectiveness_by_actor(&bob) - 0.2).abs() < 0.001);
752        // Global effectiveness is unchanged by per-actor accessor:
753        // effectiveness() = success/(success+override) = 9/15 = 0.6
754        assert!((e.effectiveness() - 0.6).abs() < 0.001);
755    }
756
757    #[test]
758    fn evidence_effectiveness_by_unknown_actor_returns_neutral_prior() {
759        use crate::{Actor, ActorSource};
760
761        let unknown = Actor {
762            source: ActorSource::MurCli,
763            native_id: "never-seen".into(),
764            display_name: None,
765            resolved_user_id: None,
766        };
767        let e = Evidence::default();
768        // No contribution exists for this actor → neutral 0.5 prior
769        assert!((e.effectiveness_by_actor(&unknown) - 0.5).abs() < 0.001);
770    }
771
772    #[test]
773    fn evidence_yaml_roundtrip_with_contributions() {
774        use crate::{Actor, ActorSource};
775
776        let actor = Actor {
777            source: ActorSource::CommanderDaemon,
778            native_id: "svc-1".into(),
779            display_name: None,
780            resolved_user_id: None,
781        };
782        let mut contribs = HashMap::new();
783        contribs.insert(
784            actor.key(),
785            Contribution {
786                success_signals: 3,
787                override_signals: 1,
788                last_seen: DateTime::parse_from_rfc3339("2026-04-18T10:00:00Z")
789                    .unwrap()
790                    .with_timezone(&Utc),
791            },
792        );
793        let e = Evidence {
794            contributions: contribs,
795            ..Evidence::default()
796        };
797        let y = serde_yaml::to_string(&e).unwrap();
798        let back: Evidence = serde_yaml::from_str(&y).unwrap();
799        assert_eq!(back.contributions.len(), 1);
800        assert_eq!(
801            back.contributions
802                .get("CommanderDaemon:svc-1")
803                .unwrap()
804                .success_signals,
805            3
806        );
807    }
808
809    #[test]
810    fn evidence_yaml_backward_compat_no_contributions_field() {
811        // Old YAML lacks the new field
812        let old_yaml = r#"
813source_sessions: []
814first_seen: null
815last_validated: null
816injection_count: 5
817success_signals: 3
818override_signals: 1
819"#;
820        let e: Evidence = serde_yaml::from_str(old_yaml).unwrap();
821        assert!(e.contributions.is_empty());
822        assert_eq!(e.success_signals, 3);
823    }
824
825    #[test]
826    fn pattern_scope_defaults_personal() {
827        // Pre-sync YAML has no `scope:` field
828        let old_yaml = r#"
829schema: 2
830name: legacy-pattern
831description: legacy
832content: old content
833tier: session
834"#;
835        let p: Pattern = serde_yaml::from_str(old_yaml).unwrap();
836        assert_eq!(p.scope, crate::Scope::Personal);
837    }
838
839    #[test]
840    fn pattern_scope_team_roundtrip() {
841        let y = r#"
842schema: 2
843name: team-pat
844description: team pattern
845content: team content
846tier: project
847scope:
848  kind: team
849  team_id: ops
850"#;
851        let p: Pattern = serde_yaml::from_str(y).unwrap();
852        assert_eq!(
853            p.scope,
854            crate::Scope::Team {
855                team_id: "ops".into()
856            }
857        );
858        // Roundtrip verify
859        let y2 = serde_yaml::to_string(&p).unwrap();
860        let p2: Pattern = serde_yaml::from_str(&y2).unwrap();
861        assert_eq!(p2.scope, p.scope);
862    }
863
864    #[test]
865    fn pattern_scope_community_roundtrip() {
866        let y = r#"
867schema: 2
868name: comm-pat
869description: community pattern
870content: community content
871tier: core
872scope:
873  kind: community
874  pack_id: rust-best-practices
875"#;
876        let p: Pattern = serde_yaml::from_str(y).unwrap();
877        assert_eq!(
878            p.scope,
879            crate::Scope::Community {
880                pack_id: Some("rust-best-practices".into())
881            }
882        );
883    }
884}