Skip to main content

kernex_memory/
observation.rs

1//! Typed observation log: input ([`SaveEntry`]), output ([`Observation`]),
2//! and the closed taxonomy ([`ObservationType`]) that backs the
3//! `kx mem save` write surface plus its `search` / `get` / `soft-delete`
4//! companions on [`crate::MemoryStore`].
5//!
6//! Project scoping comes from the on-disk DB location
7//! (`~/.kx/projects/<name>/memory.db` in the CLI). Intra-DB scoping is by
8//! `sender_id`, matching the existing `facts` and `messages` discipline.
9//! Observations carry no project column.
10
11use std::time::SystemTime;
12
13use serde::{Deserialize, Serialize};
14
15/// Closed set of observation types the CLI and store accept.
16///
17/// New variants ride a `kernex-memory` minor bump PLUS a migration that
18/// extends the SQL `CHECK` constraint. Adding a Rust variant without
19/// updating the migration silently rejects the new value at write time
20/// with a SQLite CHECK violation; the two layers must move together.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23#[non_exhaustive]
24pub enum ObservationType {
25    Bugfix,
26    Decision,
27    Pattern,
28    Config,
29    Discovery,
30    Learning,
31    Architecture,
32}
33
34impl ObservationType {
35    /// Stable lowercase identifier persisted to the `observations.type`
36    /// column. Matches the SQL `CHECK` constraint in migration 018.
37    pub fn as_db_str(&self) -> &'static str {
38        match self {
39            Self::Bugfix => "bugfix",
40            Self::Decision => "decision",
41            Self::Pattern => "pattern",
42            Self::Config => "config",
43            Self::Discovery => "discovery",
44            Self::Learning => "learning",
45            Self::Architecture => "architecture",
46        }
47    }
48
49    /// Inverse of [`Self::as_db_str`]. Returns `None` for unknown
50    /// strings so callers can map an unrecognized value (drift between
51    /// DB rows and the current Rust enum) to a domain error rather
52    /// than panicking.
53    pub fn from_db_str(s: &str) -> Option<Self> {
54        match s {
55            "bugfix" => Some(Self::Bugfix),
56            "decision" => Some(Self::Decision),
57            "pattern" => Some(Self::Pattern),
58            "config" => Some(Self::Config),
59            "discovery" => Some(Self::Discovery),
60            "learning" => Some(Self::Learning),
61            "architecture" => Some(Self::Architecture),
62            _ => None,
63        }
64    }
65}
66
67/// Operator-supplied input for [`crate::MemoryStore::save_observation`].
68///
69/// Optional fields stay [`None`] when the caller omits them. The store
70/// generates [`Observation::id`] and the timestamps; callers do not.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[non_exhaustive]
73pub struct SaveEntry {
74    pub sender_id: String,
75    #[serde(rename = "type")]
76    pub kind: ObservationType,
77    pub title: String,
78    pub what: Option<String>,
79    pub why: Option<String>,
80    #[serde(rename = "where")]
81    pub where_field: Option<String>,
82    pub learned: Option<String>,
83}
84
85impl SaveEntry {
86    /// Construct a minimal entry (only the required fields).
87    /// Optional fields default to [`None`].
88    pub fn new(
89        sender_id: impl Into<String>,
90        kind: ObservationType,
91        title: impl Into<String>,
92    ) -> Self {
93        Self {
94            sender_id: sender_id.into(),
95            kind,
96            title: title.into(),
97            what: None,
98            why: None,
99            where_field: None,
100            learned: None,
101        }
102    }
103}
104
105/// Stored observation row returned by save / get / search paths.
106///
107/// `id` is a UUIDv4 string generated by the store at write time.
108/// `created_at` and `updated_at` are wall-clock times in UTC; the DB
109/// stores them as ISO 8601 strings, and the store projects them back
110/// to [`SystemTime`] at the row-shape boundary so consumers never see
111/// raw timestamp text.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct Observation {
115    pub id: String,
116    pub sender_id: String,
117    #[serde(rename = "type")]
118    pub kind: ObservationType,
119    pub title: String,
120    pub what: Option<String>,
121    pub why: Option<String>,
122    #[serde(rename = "where")]
123    pub where_field: Option<String>,
124    pub learned: Option<String>,
125    pub created_at: SystemTime,
126    pub updated_at: SystemTime,
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn observation_type_round_trips_via_db_str() {
135        for kind in [
136            ObservationType::Bugfix,
137            ObservationType::Decision,
138            ObservationType::Pattern,
139            ObservationType::Config,
140            ObservationType::Discovery,
141            ObservationType::Learning,
142            ObservationType::Architecture,
143        ] {
144            assert_eq!(
145                ObservationType::from_db_str(kind.as_db_str()),
146                Some(kind),
147                "round-trip failed for {kind:?}"
148            );
149        }
150    }
151
152    #[test]
153    fn unknown_db_str_returns_none() {
154        assert_eq!(ObservationType::from_db_str("bogus"), None);
155        assert_eq!(ObservationType::from_db_str(""), None);
156        assert_eq!(ObservationType::from_db_str("BUGFIX"), None); // case sensitive
157    }
158
159    #[test]
160    fn save_entry_serde_round_trip() {
161        let entry = SaveEntry {
162            sender_id: "user".into(),
163            kind: ObservationType::Bugfix,
164            title: "Fixed N+1 query".into(),
165            what: Some("added eager loading".into()),
166            why: Some("12s pages on 5k users".into()),
167            where_field: Some("src/users/list.rs".into()),
168            learned: Some("FTS5 rewriter cannot fix N+1".into()),
169        };
170        let json = serde_json::to_string(&entry).unwrap();
171        // JSON uses the renamed field names (`type`, `where`) from serde attrs.
172        assert!(json.contains("\"type\":\"bugfix\""), "json was {json}");
173        assert!(
174            json.contains("\"where\":\"src/users/list.rs\""),
175            "json was {json}"
176        );
177        let parsed: SaveEntry = serde_json::from_str(&json).unwrap();
178        assert_eq!(parsed.sender_id, entry.sender_id);
179        assert_eq!(parsed.kind, entry.kind);
180        assert_eq!(parsed.title, entry.title);
181        assert_eq!(parsed.what, entry.what);
182        assert_eq!(parsed.why, entry.why);
183        assert_eq!(parsed.where_field, entry.where_field);
184        assert_eq!(parsed.learned, entry.learned);
185    }
186}