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;