Skip to main content

khive_types/
note.rs

1//! Note substrate — temporal-referential records (ADR-004, ADR-019).
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/// Closed taxonomy for note classification (ADR-019).
13///
14/// 5 kinds covering the cognitive functions an agent performs while researching.
15/// Closed and exhaustive — adding a sixth requires a new ADR.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
19pub enum NoteKind {
20    /// An empirical capture — what was noticed or measured.
21    #[default]
22    Observation,
23    /// An analytical or synthetic conclusion drawn from observations.
24    Insight,
25    /// An open inquiry, research direction, or unknown.
26    Question,
27    /// A committed choice with rationale.
28    Decision,
29    /// An external pointer with context (paper, URL, citation note).
30    Reference,
31}
32
33impl NoteKind {
34    pub const ALL: [Self; 5] = [
35        Self::Observation,
36        Self::Insight,
37        Self::Question,
38        Self::Decision,
39        Self::Reference,
40    ];
41
42    pub const fn name(self) -> &'static str {
43        match self {
44            Self::Observation => "observation",
45            Self::Insight => "insight",
46            Self::Question => "question",
47            Self::Decision => "decision",
48            Self::Reference => "reference",
49        }
50    }
51}
52
53impl fmt::Display for NoteKind {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str(self.name())
56    }
57}
58
59const NOTE_KIND_VALID: &[&str] = &[
60    "observation",
61    "insight",
62    "question",
63    "decision",
64    "reference",
65];
66
67impl core::str::FromStr for NoteKind {
68    type Err = crate::error::UnknownVariant;
69
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        match s.trim().to_ascii_lowercase().as_str() {
72            "observation" | "obs" => Ok(Self::Observation),
73            "insight" | "finding" => Ok(Self::Insight),
74            "question" | "q" => Ok(Self::Question),
75            "decision" | "choice" => Ok(Self::Decision),
76            "reference" | "ref" | "citation" => Ok(Self::Reference),
77            other => Err(crate::error::UnknownVariant::new(
78                "note_kind",
79                other,
80                NOTE_KIND_VALID,
81            )),
82        }
83    }
84}
85
86/// Lifecycle status of a note. Cross-cutting across all note kinds.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
89#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
90pub enum NoteStatus {
91    #[default]
92    Active,
93    Archived,
94}
95
96impl NoteStatus {
97    pub const fn name(self) -> &'static str {
98        match self {
99            Self::Active => "active",
100            Self::Archived => "archived",
101        }
102    }
103}
104
105impl fmt::Display for NoteStatus {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(self.name())
108    }
109}
110
111/// A note record — temporal-referential content plus free-form properties.
112#[derive(Clone, Debug)]
113#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
114pub struct Note {
115    #[cfg_attr(feature = "serde", serde(flatten))]
116    pub header: Header,
117    pub kind: NoteKind,
118    pub status: NoteStatus,
119    pub content: String,
120    pub properties: BTreeMap<String, PropertyValue>,
121    pub tags: Vec<String>,
122    pub salience: f64,
123    pub decay_factor: f64,
124    pub expires_at: Option<Timestamp>,
125    pub deleted_at: Option<Timestamp>,
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::{Id128, Namespace};
132
133    fn test_header() -> Header {
134        Header::new(
135            Id128::from_u128(1),
136            Namespace::default(),
137            Timestamp::from_secs(1700000000),
138        )
139    }
140
141    #[test]
142    fn note_kind_all_have_names() {
143        for kind in NoteKind::ALL {
144            assert!(!kind.name().is_empty());
145        }
146    }
147
148    #[test]
149    fn note_kind_default_is_observation() {
150        assert_eq!(NoteKind::default(), NoteKind::Observation);
151    }
152
153    #[test]
154    fn note_kind_display_roundtrip() {
155        use core::str::FromStr;
156        for kind in NoteKind::ALL {
157            let s = alloc::format!("{kind}");
158            let parsed = NoteKind::from_str(&s).unwrap();
159            assert_eq!(parsed, kind);
160        }
161    }
162
163    #[test]
164    fn note_kind_from_str_case_insensitive() {
165        use core::str::FromStr;
166        assert_eq!(
167            NoteKind::from_str("OBSERVATION").unwrap(),
168            NoteKind::Observation
169        );
170        assert_eq!(NoteKind::from_str("Insight").unwrap(), NoteKind::Insight);
171    }
172
173    #[test]
174    fn note_kind_from_str_aliases() {
175        use core::str::FromStr;
176        assert_eq!(NoteKind::from_str("obs").unwrap(), NoteKind::Observation);
177        assert_eq!(NoteKind::from_str("finding").unwrap(), NoteKind::Insight);
178        assert_eq!(NoteKind::from_str("q").unwrap(), NoteKind::Question);
179        assert_eq!(NoteKind::from_str("choice").unwrap(), NoteKind::Decision);
180        assert_eq!(NoteKind::from_str("ref").unwrap(), NoteKind::Reference);
181        assert_eq!(NoteKind::from_str("citation").unwrap(), NoteKind::Reference);
182    }
183
184    #[test]
185    fn note_kind_from_str_unknown_errors() {
186        use core::str::FromStr;
187        let err = NoteKind::from_str("garbage").unwrap_err();
188        assert_eq!(err.domain, "note_kind");
189        assert_eq!(err.value, "garbage");
190        assert!(err.valid.contains(&"observation"));
191    }
192
193    #[test]
194    fn note_construction() {
195        let note = Note {
196            header: test_header(),
197            kind: NoteKind::Decision,
198            status: NoteStatus::Active,
199            content: String::from("Use BGE-base for multilingual corpus"),
200            properties: BTreeMap::new(),
201            tags: alloc::vec!["retrieval".into()],
202            salience: 0.8,
203            decay_factor: 0.01,
204            expires_at: None,
205            deleted_at: None,
206        };
207        assert_eq!(note.kind, NoteKind::Decision);
208        assert_eq!(note.tags.len(), 1);
209    }
210}