Skip to main content

khive_types/
note.rs

1//! Note substrate — temporal-referential records used throughout khive.
2
3extern crate alloc;
4use alloc::collections::BTreeMap;
5use alloc::string::String;
6use alloc::vec::Vec;
7use core::fmt;
8
9use crate::entity::PropertyValue;
10use crate::{Header, Timestamp};
11
12/// Lifecycle status of a note. Cross-cutting across all note kinds.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
16pub enum NoteStatus {
17    #[default]
18    Active,
19    Archived,
20    Deleted,
21}
22
23impl NoteStatus {
24    /// Return the canonical lowercase string for this status, as stored on the wire.
25    pub const fn name(self) -> &'static str {
26        match self {
27            Self::Active => "active",
28            Self::Archived => "archived",
29            Self::Deleted => "deleted",
30        }
31    }
32}
33
34impl fmt::Display for NoteStatus {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(self.name())
37    }
38}
39
40impl core::str::FromStr for NoteStatus {
41    type Err = crate::error::UnknownVariant;
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        match s.trim().to_ascii_lowercase().as_str() {
44            "active" => Ok(Self::Active),
45            "archived" => Ok(Self::Archived),
46            "deleted" => Ok(Self::Deleted),
47            other => Err(crate::error::UnknownVariant::new(
48                "note_status",
49                other,
50                &["active", "archived", "deleted"],
51            )),
52        }
53    }
54}
55
56/// A note record — temporal-referential content plus free-form properties.
57///
58/// When present, `salience` must be finite and in `[0.0, 1.0]`, and
59/// `decay_factor` must be finite and non-negative. When the `serde` feature
60/// is enabled, deserialization rejects out-of-range values.
61#[derive(Clone, Debug)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize))]
63pub struct Note {
64    /// Identity and namespace metadata shared by all substrate records.
65    #[cfg_attr(feature = "serde", serde(flatten))]
66    pub header: Header,
67    /// Pack-declared kind string (e.g. `"observation"`, `"task"`, `"memory"`).
68    pub kind: String,
69    /// Cross-cutting lifecycle status.
70    pub status: NoteStatus,
71    /// Main textual body of the note.
72    pub content: String,
73    /// Arbitrary structured metadata as key-value pairs.
74    pub properties: BTreeMap<String, PropertyValue>,
75    /// Categorical labels for filtering and retrieval.
76    pub tags: Vec<String>,
77    /// Retrieval priority weight in [0.0, 1.0]; higher values surface the note sooner.
78    pub salience: Option<f64>,
79    /// Exponential decay rate applied to salience over time; 0.0 means no decay.
80    pub decay_factor: Option<f64>,
81    /// Optional expiry timestamp after which the note is treated as inactive.
82    pub expires_at: Option<Timestamp>,
83    /// Set when the note is soft-deleted; absent means active.
84    pub deleted_at: Option<Timestamp>,
85}
86
87impl Note {
88    /// Return `true` if all numeric fields carry finite, domain-valid values.
89    ///
90    /// - `salience`, if present, must be finite and in `[0.0, 1.0]`.
91    /// - `decay_factor`, if present, must be finite and non-negative.
92    pub fn is_valid(&self) -> bool {
93        let salience_ok = self
94            .salience
95            .map(|s| s.is_finite() && (0.0..=1.0).contains(&s))
96            .unwrap_or(true);
97        let decay_ok = self
98            .decay_factor
99            .map(|d| d.is_finite() && d >= 0.0)
100            .unwrap_or(true);
101        salience_ok && decay_ok
102    }
103}
104
105#[cfg(feature = "serde")]
106impl<'de> serde::Deserialize<'de> for Note {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: serde::Deserializer<'de>,
110    {
111        #[derive(serde::Deserialize)]
112        struct NoteRaw {
113            #[serde(flatten)]
114            header: Header,
115            kind: String,
116            status: NoteStatus,
117            content: String,
118            properties: BTreeMap<String, PropertyValue>,
119            tags: Vec<String>,
120            salience: Option<f64>,
121            decay_factor: Option<f64>,
122            expires_at: Option<Timestamp>,
123            deleted_at: Option<Timestamp>,
124        }
125
126        let raw = NoteRaw::deserialize(deserializer)?;
127
128        if let Some(s) = raw.salience {
129            if !s.is_finite() {
130                return Err(serde::de::Error::custom(alloc::format!(
131                    "Note salience must be finite, got {s}"
132                )));
133            }
134            if !(0.0..=1.0).contains(&s) {
135                return Err(serde::de::Error::custom(alloc::format!(
136                    "Note salience must be in [0.0, 1.0], got {s}"
137                )));
138            }
139        }
140        if let Some(d) = raw.decay_factor {
141            if !d.is_finite() {
142                return Err(serde::de::Error::custom(alloc::format!(
143                    "Note decay_factor must be finite, got {d}"
144                )));
145            }
146            if d < 0.0 {
147                return Err(serde::de::Error::custom(alloc::format!(
148                    "Note decay_factor must be non-negative, got {d}"
149                )));
150            }
151        }
152
153        Ok(Note {
154            header: raw.header,
155            kind: raw.kind,
156            status: raw.status,
157            content: raw.content,
158            properties: raw.properties,
159            tags: raw.tags,
160            salience: raw.salience,
161            decay_factor: raw.decay_factor,
162            expires_at: raw.expires_at,
163            deleted_at: raw.deleted_at,
164        })
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::{Id128, Namespace};
172    #[cfg(feature = "serde")]
173    use alloc::string::ToString;
174
175    fn test_header() -> Header {
176        Header::new(
177            Id128::from_u128(1),
178            Namespace::local(),
179            Timestamp::from_secs(1700000000),
180        )
181    }
182
183    #[test]
184    fn note_construction() {
185        let note = Note {
186            header: test_header(),
187            kind: String::from("decision"),
188            status: NoteStatus::Active,
189            content: String::from("Use BGE-base for multilingual corpus"),
190            properties: BTreeMap::new(),
191            tags: alloc::vec!["retrieval".into()],
192            salience: Some(0.8),
193            decay_factor: Some(0.01),
194            expires_at: None,
195            deleted_at: None,
196        };
197        assert_eq!(note.kind, "decision");
198        assert_eq!(note.tags.len(), 1);
199    }
200
201    #[test]
202    fn note_construction_uses_pack_owned_kind_string() {
203        let note = Note {
204            header: test_header(),
205            kind: String::from("decision"),
206            status: NoteStatus::Active,
207            content: String::from("test"),
208            properties: BTreeMap::new(),
209            tags: alloc::vec![],
210            salience: None,
211            decay_factor: None,
212            expires_at: None,
213            deleted_at: None,
214        };
215        assert_eq!(note.kind, "decision");
216    }
217
218    #[test]
219    fn note_status_deleted_roundtrip() {
220        use core::str::FromStr;
221        assert_eq!(
222            NoteStatus::from_str("deleted").unwrap(),
223            NoteStatus::Deleted
224        );
225        assert_eq!(NoteStatus::Deleted.name(), "deleted");
226    }
227
228    #[test]
229    fn note_is_valid_checks_salience_range() {
230        let mut note = Note {
231            header: test_header(),
232            kind: String::from("observation"),
233            status: NoteStatus::Active,
234            content: String::from("test"),
235            properties: BTreeMap::new(),
236            tags: alloc::vec![],
237            salience: Some(1.5),
238            decay_factor: None,
239            expires_at: None,
240            deleted_at: None,
241        };
242        assert!(!note.is_valid());
243        note.salience = Some(0.5);
244        assert!(note.is_valid());
245    }
246
247    #[test]
248    fn note_is_valid_checks_decay_non_negative() {
249        let mut note = Note {
250            header: test_header(),
251            kind: String::from("observation"),
252            status: NoteStatus::Active,
253            content: String::from("test"),
254            properties: BTreeMap::new(),
255            tags: alloc::vec![],
256            salience: None,
257            decay_factor: Some(-0.1),
258            expires_at: None,
259            deleted_at: None,
260        };
261        assert!(!note.is_valid());
262        note.decay_factor = Some(0.01);
263        assert!(note.is_valid());
264    }
265
266    #[cfg(feature = "serde")]
267    #[test]
268    fn note_serde_rejects_salience_above_one() {
269        let json = serde_json::json!({
270            "id": "00000000-0000-0000-0000-000000000001",
271            "namespace": "local",
272            "created_at": 1700000000000000_u64,
273            "updated_at": 1700000000000000_u64,
274            "kind": "observation",
275            "status": "active",
276            "content": "test",
277            "properties": {},
278            "tags": [],
279            "salience": 1.5,
280            "decay_factor": null,
281            "expires_at": null,
282            "deleted_at": null
283        });
284        let result: Result<Note, _> = serde_json::from_value(json);
285        assert!(result.is_err());
286        let err = result.unwrap_err().to_string();
287        assert!(
288            err.contains("[0.0, 1.0]"),
289            "error should mention range: {err}"
290        );
291    }
292
293    #[cfg(feature = "serde")]
294    #[test]
295    fn note_serde_rejects_negative_decay() {
296        let json = serde_json::json!({
297            "id": "00000000-0000-0000-0000-000000000001",
298            "namespace": "local",
299            "created_at": 1700000000000000_u64,
300            "updated_at": 1700000000000000_u64,
301            "kind": "observation",
302            "status": "active",
303            "content": "test",
304            "properties": {},
305            "tags": [],
306            "salience": null,
307            "decay_factor": -0.5,
308            "expires_at": null,
309            "deleted_at": null
310        });
311        let result: Result<Note, _> = serde_json::from_value(json);
312        assert!(result.is_err());
313        let err = result.unwrap_err().to_string();
314        assert!(
315            err.contains("non-negative"),
316            "error should mention non-negative: {err}"
317        );
318    }
319
320    #[cfg(feature = "serde")]
321    #[test]
322    fn note_serde_accepts_valid_values() {
323        let json = serde_json::json!({
324            "id": "00000000-0000-0000-0000-000000000001",
325            "namespace": "local",
326            "created_at": 1700000000000000_u64,
327            "updated_at": 1700000000000000_u64,
328            "kind": "decision",
329            "status": "active",
330            "content": "test content",
331            "properties": {},
332            "tags": ["tag1"],
333            "salience": 0.8,
334            "decay_factor": 0.01,
335            "expires_at": null,
336            "deleted_at": null
337        });
338        let note: Note = serde_json::from_value(json).expect("valid note should deserialize");
339        assert_eq!(note.salience, Some(0.8));
340        assert_eq!(note.decay_factor, Some(0.01));
341    }
342}