Skip to main content

csm_memory/
concept_builder.rs

1//! Concept builder for ergonomic concept construction
2
3use serde::Serialize;
4use std::collections::HashMap;
5
6use crate::singularity::Concept;
7use csm_core_lib::error::{MemoryError, Result};
8use csm_core_lib::hyperdim::HVec10240;
9
10/// Builder for constructing [`Concept`] instances with a fluent API.
11///
12/// # Example
13///
14/// ```
15/// use csm_memory::ConceptBuilder;
16/// use csm_core_lib::hyperdim::HVec10240;
17///
18/// let concept = ConceptBuilder::new("example")
19///     .with_vector(HVec10240::random())
20///     .with_metadata("source", "test")
21///     .build()
22///     .unwrap();
23/// ```
24#[derive(Debug)]
25pub struct ConceptBuilder {
26    id: String,
27    vector: Option<HVec10240>,
28    metadata: HashMap<String, serde_json::Value>,
29    metadata_error: Option<MemoryError>,
30    ttl_seconds: Option<u64>,
31    canonical_concept_ids: Vec<String>,
32}
33
34impl ConceptBuilder {
35    /// Creates a new builder for a concept with the given ID.
36    #[must_use]
37    pub fn new(id: impl Into<String>) -> Self {
38        Self {
39            id: id.into(),
40            vector: None,
41            metadata: HashMap::new(),
42            metadata_error: None,
43            ttl_seconds: None,
44            canonical_concept_ids: Vec::new(),
45        }
46    }
47
48    /// Sets the canonical concept IDs for semantic bridge linking.
49    #[must_use]
50    pub fn with_canonical_concepts(mut self, ids: Vec<String>) -> Self {
51        self.canonical_concept_ids = ids;
52        self
53    }
54
55    /// Sets the TTL (time to live) in seconds for this concept.
56    ///
57    /// The concept will expire after `ttl_seconds` from creation.
58    /// If not set, the concept never expires.
59    #[must_use]
60    pub const fn with_ttl(mut self, ttl_seconds: u64) -> Self {
61        self.ttl_seconds = Some(ttl_seconds);
62        self
63    }
64
65    /// Sets the vector for this concept.
66    #[must_use]
67    pub const fn with_vector(mut self, vector: HVec10240) -> Self {
68        self.vector = Some(vector);
69        self
70    }
71
72    /// Adds metadata to this concept.
73    ///
74    /// If serialization of the value fails, the error is captured and
75    /// will be returned when `build()` is called.
76    #[must_use]
77    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
78        if self.metadata_error.is_none() {
79            match serde_json::to_value(value) {
80                Ok(value) => {
81                    self.metadata.insert(key.into(), value);
82                }
83                Err(error) => {
84                    self.metadata_error = Some(MemoryError::Serialization(error));
85                }
86            }
87        }
88        self
89    }
90
91    /// Builds the [`Concept`] instance.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if metadata serialization failed during construction.
96    pub fn build(self) -> Result<Concept> {
97        if let Some(error) = self.metadata_error {
98            return Err(error);
99        }
100
101        let now = crate::singularity::unix_now_secs();
102        let expires_at = self.ttl_seconds.map(|ttl| now + ttl);
103
104        Ok(Concept {
105            id: self.id,
106            vector: self.vector.unwrap_or_else(HVec10240::random),
107            metadata: self.metadata,
108            created_at: now,
109            modified_at: now,
110            expires_at,
111            canonical_concept_ids: self.canonical_concept_ids,
112        })
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
119    use super::*;
120
121    #[test]
122    fn concept_builder_creates_concept_with_metadata() {
123        let concept = ConceptBuilder::new("test-id")
124            .with_vector(HVec10240::random())
125            .with_metadata("key1", "value1")
126            .with_metadata("key2", 42i32)
127            .build()
128            .unwrap();
129
130        assert_eq!(concept.id, "test-id");
131        assert_eq!(
132            concept.metadata.get("key1").unwrap().as_str().unwrap(),
133            "value1"
134        );
135        assert_eq!(concept.metadata.get("key2").unwrap().as_i64().unwrap(), 42);
136    }
137
138    #[test]
139    fn concept_builder_uses_random_vector_by_default() {
140        let concept = ConceptBuilder::new("test").build().unwrap();
141        // Just verify it builds successfully without explicit vector
142        assert_eq!(concept.id, "test");
143    }
144
145    #[test]
146    fn concept_builder_with_ttl_sets_expiration() {
147        let now = crate::singularity::unix_now_secs();
148        let concept = ConceptBuilder::new("ttl-test")
149            .with_ttl(3600)
150            .build()
151            .unwrap();
152
153        assert!(concept.expires_at.is_some());
154        let expires_at = concept.expires_at.unwrap();
155        // Expiration should be approximately now + 3600
156        assert!(expires_at >= now + 3600 - 1);
157        assert!(expires_at <= now + 3600 + 1);
158    }
159
160    #[test]
161    fn concept_builder_without_ttl_has_no_expiration() {
162        let concept = ConceptBuilder::new("no-ttl").build().unwrap();
163        assert!(concept.expires_at.is_none());
164    }
165}