Skip to main content

brainwires_knowledge/knowledge/
thought.rs

1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use uuid::Uuid;
5
6/// A persistent thought stored in the Open Brain.
7///
8/// Thoughts are the primary unit of knowledge capture — explicit records
9/// of decisions, insights, people, action items, and more that persist
10/// with Canonical authority (no TTL, never auto-evicted).
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Thought {
13    /// Unique identifier (UUID).
14    pub id: String,
15    /// The thought content text.
16    pub content: String,
17    /// Category for filtering and organisation.
18    pub category: ThoughtCategory,
19    /// User-provided or auto-extracted tags.
20    pub tags: Vec<String>,
21    /// How the thought was captured.
22    pub source: ThoughtSource,
23    /// Importance score in 0.0--1.0.
24    pub importance: f32,
25    /// Unix timestamp of creation.
26    pub created_at: i64,
27    /// Unix timestamp of last update.
28    pub updated_at: i64,
29    /// Soft-delete flag.
30    pub deleted: bool,
31    /// Confidence in this thought, updated via EMA as corroborations/contradictions arrive.
32    pub confidence: f32,
33    /// IDs of other thoughts that form an evidence chain with this one.
34    pub evidence_chain: Vec<String>,
35    /// How many times this thought has been corroborated by new evidence.
36    pub reinforcement_count: u32,
37    /// How many times this thought has been contradicted by new evidence.
38    pub contradiction_count: u32,
39}
40
41impl Thought {
42    /// Create a new thought with the given content and defaults.
43    pub fn new(content: String) -> Self {
44        let now = Utc::now().timestamp();
45        Self {
46            id: Uuid::new_v4().to_string(),
47            content,
48            category: ThoughtCategory::General,
49            tags: Vec::new(),
50            source: ThoughtSource::ManualCapture,
51            importance: 0.5,
52            created_at: now,
53            updated_at: now,
54            deleted: false,
55            confidence: 0.5,
56            evidence_chain: Vec::new(),
57            reinforcement_count: 0,
58            contradiction_count: 0,
59        }
60    }
61
62    /// Builder: set category.
63    pub fn with_category(mut self, category: ThoughtCategory) -> Self {
64        self.category = category;
65        self
66    }
67
68    /// Builder: set tags.
69    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
70        self.tags = tags;
71        self
72    }
73
74    /// Builder: set source.
75    pub fn with_source(mut self, source: ThoughtSource) -> Self {
76        self.source = source;
77        self
78    }
79
80    /// Builder: set importance (clamped to 0.0–1.0).
81    pub fn with_importance(mut self, importance: f32) -> Self {
82        self.importance = importance.clamp(0.0, 1.0);
83        self
84    }
85}
86
87/// Category of a thought, used for filtering and organisation.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum ThoughtCategory {
91    /// A decision that was made.
92    Decision,
93    /// A person mentioned or discussed.
94    Person,
95    /// An insight or observation.
96    Insight,
97    /// Notes from a meeting.
98    MeetingNote,
99    /// An idea or proposal.
100    Idea,
101    /// An action item or TODO.
102    ActionItem,
103    /// A reference link or document.
104    Reference,
105    /// Auto-captured conversation turn.
106    Conversation,
107    /// General uncategorised thought.
108    General,
109}
110
111impl ThoughtCategory {
112    /// All variants for iteration.
113    pub const ALL: &[ThoughtCategory] = &[
114        Self::Decision,
115        Self::Person,
116        Self::Insight,
117        Self::MeetingNote,
118        Self::Idea,
119        Self::ActionItem,
120        Self::Reference,
121        Self::Conversation,
122        Self::General,
123    ];
124
125    /// Returns the snake_case string representation.
126    pub fn as_str(&self) -> &'static str {
127        match self {
128            Self::Decision => "decision",
129            Self::Person => "person",
130            Self::Insight => "insight",
131            Self::MeetingNote => "meeting_note",
132            Self::Idea => "idea",
133            Self::ActionItem => "action_item",
134            Self::Reference => "reference",
135            Self::Conversation => "conversation",
136            Self::General => "general",
137        }
138    }
139
140    /// Parse a string into a category, defaulting to `General`.
141    pub fn parse(s: &str) -> Self {
142        match s.to_lowercase().as_str() {
143            "decision" => Self::Decision,
144            "person" => Self::Person,
145            "insight" => Self::Insight,
146            "meeting_note" | "meetingnote" => Self::MeetingNote,
147            "idea" => Self::Idea,
148            "action_item" | "actionitem" | "todo" => Self::ActionItem,
149            "reference" | "ref" => Self::Reference,
150            "conversation" | "conversation_extract" => Self::Conversation,
151            _ => Self::General,
152        }
153    }
154}
155
156impl fmt::Display for ThoughtCategory {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.write_str(self.as_str())
159    }
160}
161
162/// How a thought was captured.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum ThoughtSource {
166    /// User explicitly captured the thought.
167    ManualCapture,
168    /// Extracted from conversation context.
169    ConversationExtract,
170    /// Imported from external source.
171    Import,
172}
173
174impl ThoughtSource {
175    /// Returns the snake_case string representation.
176    pub fn as_str(&self) -> &'static str {
177        match self {
178            Self::ManualCapture => "manual",
179            Self::ConversationExtract => "conversation",
180            Self::Import => "import",
181        }
182    }
183
184    /// Parse a string into a source, defaulting to `ManualCapture`.
185    pub fn parse(s: &str) -> Self {
186        match s.to_lowercase().as_str() {
187            "manual" | "manual_capture" => Self::ManualCapture,
188            "conversation" | "conversation_extract" => Self::ConversationExtract,
189            "import" => Self::Import,
190            _ => Self::ManualCapture,
191        }
192    }
193}
194
195impl fmt::Display for ThoughtSource {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        f.write_str(self.as_str())
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_thought_creation() {
207        let thought = Thought::new("Test thought".into())
208            .with_category(ThoughtCategory::Decision)
209            .with_tags(vec!["rust".into(), "architecture".into()])
210            .with_importance(0.8);
211
212        assert_eq!(thought.category, ThoughtCategory::Decision);
213        assert_eq!(thought.tags.len(), 2);
214        assert!((thought.importance - 0.8).abs() < f32::EPSILON);
215        assert!(!thought.deleted);
216    }
217
218    #[test]
219    fn test_category_roundtrip() {
220        for cat in ThoughtCategory::ALL {
221            let s = cat.as_str();
222            let parsed = ThoughtCategory::parse(s);
223            assert_eq!(*cat, parsed);
224        }
225    }
226
227    #[test]
228    fn test_importance_clamped() {
229        let t = Thought::new("x".into()).with_importance(1.5);
230        assert!((t.importance - 1.0).abs() < f32::EPSILON);
231
232        let t = Thought::new("x".into()).with_importance(-0.5);
233        assert!((t.importance - 0.0).abs() < f32::EPSILON);
234    }
235}