Skip to main content

engram/intelligence/
emotional.rs

1//! Emotional & Reflective Memory — RML-1215
2//!
3//! OpenMemory-inspired emotional analysis and reflection engine.
4//!
5//! Provides:
6//! - Rule-based sentiment analysis (no external dependencies)
7//! - Reflection generation at Surface / Analytical / Meta depth
8//! - Temporal sentiment timelines over a date range
9//!
10//! ## Invariants
11//! - Sentiment scores are always in the range [-1.0, 1.0]
12//! - Confidence scores are always in the range [0.0, 1.0]
13//! - Empty/whitespace input returns `Neutral` sentiment with score 0.0
14//! - Reflection content is never empty
15//! - All timestamps are RFC3339 UTC
16
17use std::collections::HashMap;
18
19use chrono::Utc;
20use rusqlite::{params, Connection};
21use serde::{Deserialize, Serialize};
22
23use crate::error::Result;
24
25// =============================================================================
26// Types
27// =============================================================================
28
29/// High-level sentiment polarity label
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SentimentLabel {
33    /// Predominantly positive sentiment
34    Positive,
35    /// Predominantly negative sentiment
36    Negative,
37    /// Neither positive nor negative
38    Neutral,
39    /// Significant mixture of positive and negative signals
40    Mixed,
41}
42
43impl SentimentLabel {
44    pub fn as_str(&self) -> &'static str {
45        match self {
46            SentimentLabel::Positive => "positive",
47            SentimentLabel::Negative => "negative",
48            SentimentLabel::Neutral => "neutral",
49            SentimentLabel::Mixed => "mixed",
50        }
51    }
52}
53
54/// Result of sentiment analysis on a piece of text
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Sentiment {
57    /// Aggregate score in [-1.0, 1.0]; -1 = most negative, +1 = most positive
58    pub score: f32,
59    /// Qualitative label derived from the score
60    pub label: SentimentLabel,
61    /// Confidence in the classification, in [0.0, 1.0]
62    pub confidence: f32,
63    /// Sentiment-bearing keywords found in the text
64    pub keywords: Vec<String>,
65}
66
67/// Depth of a reflection — controls how much processing is done
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum ReflectionDepth {
71    /// Identify key themes; one-pass, fast
72    Surface,
73    /// Find patterns, sentiment trends, and contradictions; multi-pass
74    Analytical,
75    /// Reflect on existing reflections; requires prior saved reflections
76    Meta,
77}
78
79impl ReflectionDepth {
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            ReflectionDepth::Surface => "surface",
83            ReflectionDepth::Analytical => "analytical",
84            ReflectionDepth::Meta => "meta",
85        }
86    }
87}
88
89/// A synthesised reflection over one or more memories
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Reflection {
92    /// Database-assigned id (0 when not yet persisted)
93    pub id: i64,
94    /// Narrative content of the reflection
95    pub content: String,
96    /// IDs of the source memories that generated this reflection
97    pub source_ids: Vec<i64>,
98    /// How deeply this reflection was generated
99    pub depth: ReflectionDepth,
100    /// Key insights distilled from the source memories
101    pub insights: Vec<String>,
102    /// RFC3339 UTC creation timestamp
103    pub created_at: String,
104}
105
106/// A single data point on a sentiment timeline
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SentimentPoint {
109    /// RFC3339 UTC timestamp of the underlying memory
110    pub timestamp: String,
111    /// Sentiment score in [-1.0, 1.0]
112    pub score: f32,
113    /// ID of the memory this point was derived from
114    pub memory_id: i64,
115}
116
117// =============================================================================
118// DDL
119// =============================================================================
120
121/// DDL for the reflections table — call once during schema setup.
122pub const CREATE_REFLECTIONS_TABLE: &str = r#"
123    CREATE TABLE IF NOT EXISTS reflections (
124        id        INTEGER PRIMARY KEY AUTOINCREMENT,
125        content   TEXT    NOT NULL,
126        source_ids TEXT   NOT NULL DEFAULT '[]',
127        depth     TEXT    NOT NULL DEFAULT 'surface',
128        created_at TEXT   NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
129    );
130    CREATE INDEX IF NOT EXISTS idx_reflections_depth      ON reflections(depth);
131    CREATE INDEX IF NOT EXISTS idx_reflections_created_at ON reflections(created_at);
132"#;
133
134// =============================================================================
135// Word lists
136// =============================================================================
137
138/// Words that carry positive sentiment
139static POSITIVE_WORDS: &[&str] = &[
140    "good",
141    "great",
142    "excellent",
143    "happy",
144    "love",
145    "amazing",
146    "wonderful",
147    "fantastic",
148    "brilliant",
149    "awesome",
150    "perfect",
151    "beautiful",
152    "outstanding",
153    "superb",
154    "delightful",
155    "pleased",
156    "grateful",
157    "thrilled",
158    "excited",
159    "proud",
160    "successful",
161    "efficient",
162    "impressive",
163    "remarkable",
164    "enjoyable",
165    "positive",
166    "beneficial",
167    "valuable",
168    "productive",
169    "innovative",
170    "elegant",
171    "smooth",
172    "clean",
173    "fast",
174    "reliable",
175    "stable",
176    "robust",
177    "secure",
178    "scalable",
179    "optimal",
180];
181
182/// Words that carry negative sentiment
183static NEGATIVE_WORDS: &[&str] = &[
184    "bad",
185    "terrible",
186    "awful",
187    "hate",
188    "horrible",
189    "poor",
190    "worst",
191    "ugly",
192    "broken",
193    "failed",
194    "error",
195    "bug",
196    "crash",
197    "slow",
198    "wrong",
199    "missing",
200    "confusing",
201    "frustrating",
202    "annoying",
203    "difficult",
204    "complicated",
205    "messy",
206    "unstable",
207    "insecure",
208    "vulnerable",
209    "deprecated",
210    "outdated",
211    "bloated",
212    "fragile",
213    "flaky",
214    "painful",
215    "tedious",
216    "cumbersome",
217    "clunky",
218    "hacky",
219    "legacy",
220    "technical-debt",
221    "regression",
222    "leak",
223    "bottleneck",
224];
225
226/// Words that negate the sentiment of the following word
227static NEGATION_WORDS: &[&str] = &[
228    "not", "no", "never", "don't", "doesn't", "isn't", "aren't", "wasn't", "can't", "won't",
229];
230
231/// Words that amplify the magnitude of the following sentiment word
232static INTENSIFIERS: &[&str] = &["very", "extremely", "really", "absolutely", "incredibly"];
233
234/// Multiplier applied when an intensifier precedes a sentiment word
235const INTENSIFIER_MULTIPLIER: f32 = 1.5;
236
237// =============================================================================
238// SentimentAnalyzer
239// =============================================================================
240
241/// Rule-based sentiment analyser with negation and intensifier support.
242///
243/// No external dependencies — works entirely from static word lists.
244pub struct SentimentAnalyzer;
245
246impl SentimentAnalyzer {
247    pub fn new() -> Self {
248        Self
249    }
250
251    /// Analyse the sentiment of `text` and return a [`Sentiment`].
252    ///
253    /// The algorithm:
254    /// 1. Tokenise by whitespace, lowercasing and stripping punctuation.
255    /// 2. Walk tokens left-to-right, tracking negation and intensifier state.
256    /// 3. Accumulate a raw score; collect matched keywords.
257    /// 4. Normalise score to [-1.0, 1.0] and derive a label.
258    pub fn analyze(&self, text: &str) -> Sentiment {
259        if text.trim().is_empty() {
260            return Sentiment {
261                score: 0.0,
262                label: SentimentLabel::Neutral,
263                confidence: 1.0,
264                keywords: Vec::new(),
265            };
266        }
267
268        let tokens: Vec<String> = text
269            .split_whitespace()
270            .map(|t| t.to_lowercase())
271            .map(|t| {
272                t.trim_matches(|c: char| !c.is_alphanumeric() && c != '-')
273                    .to_string()
274            })
275            .filter(|t| !t.is_empty())
276            .collect();
277
278        let mut raw_score: f32 = 0.0;
279        let mut keywords: Vec<String> = Vec::new();
280        let mut negated = false;
281        let mut intensify = false;
282        let mut pos_hits: u32 = 0;
283        let mut neg_hits: u32 = 0;
284
285        for token in &tokens {
286            if NEGATION_WORDS.contains(&token.as_str()) {
287                negated = true;
288                intensify = false;
289                continue;
290            }
291
292            if INTENSIFIERS.contains(&token.as_str()) {
293                intensify = true;
294                continue;
295            }
296
297            let base_delta = if POSITIVE_WORDS.contains(&token.as_str()) {
298                keywords.push(token.clone());
299                pos_hits += 1;
300                1.0_f32
301            } else if NEGATIVE_WORDS.contains(&token.as_str()) {
302                keywords.push(token.clone());
303                neg_hits += 1;
304                -1.0_f32
305            } else {
306                // Not a sentiment word — reset context flags
307                negated = false;
308                intensify = false;
309                continue;
310            };
311
312            let mut delta = base_delta;
313            if intensify {
314                delta *= INTENSIFIER_MULTIPLIER;
315            }
316            if negated {
317                delta = -delta;
318            }
319
320            raw_score += delta;
321
322            // Reset context flags after consuming the sentiment word
323            negated = false;
324            intensify = false;
325        }
326
327        let total_hits = pos_hits + neg_hits;
328
329        // Normalise: clamp to [-1.0, 1.0]
330        let score = if total_hits == 0 {
331            0.0
332        } else {
333            (raw_score / (total_hits as f32)).clamp(-1.0, 1.0)
334        };
335
336        // Confidence grows with the number of signal words found
337        let confidence = if total_hits == 0 {
338            0.5 // uncertain when no signal words are found
339        } else {
340            (0.5 + (total_hits as f32 * 0.1)).min(1.0)
341        };
342
343        // Label
344        let label = if total_hits == 0 {
345            SentimentLabel::Neutral
346        } else if pos_hits > 0 && neg_hits > 0 {
347            // Mixed only when both polarities are present in meaningful quantities
348            let ratio = pos_hits.min(neg_hits) as f32 / pos_hits.max(neg_hits) as f32;
349            if ratio > 0.3 {
350                SentimentLabel::Mixed
351            } else if score > 0.0 {
352                SentimentLabel::Positive
353            } else {
354                SentimentLabel::Negative
355            }
356        } else if score > 0.0 {
357            SentimentLabel::Positive
358        } else {
359            SentimentLabel::Negative
360        };
361
362        Sentiment {
363            score,
364            label,
365            confidence,
366            keywords,
367        }
368    }
369}
370
371impl Default for SentimentAnalyzer {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377// =============================================================================
378// ReflectionEngine
379// =============================================================================
380
381/// Generates, persists, and retrieves reflections over memory content.
382pub struct ReflectionEngine {
383    analyzer: SentimentAnalyzer,
384}
385
386impl ReflectionEngine {
387    pub fn new() -> Self {
388        Self {
389            analyzer: SentimentAnalyzer::new(),
390        }
391    }
392
393    /// Generate a [`Reflection`] from a set of `(memory_id, content)` pairs.
394    ///
395    /// The reflection is **not** automatically saved to the database.
396    /// Call [`save_reflection`] to persist it.
397    pub fn create_reflection(
398        &self,
399        conn: &Connection,
400        memory_contents: &[(i64, &str)],
401        depth: ReflectionDepth,
402    ) -> Result<Reflection> {
403        let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
404        let source_ids: Vec<i64> = memory_contents.iter().map(|(id, _)| *id).collect();
405
406        let (content, insights) = match depth {
407            ReflectionDepth::Surface => self.surface_reflect(memory_contents),
408            ReflectionDepth::Analytical => self.analytical_reflect(memory_contents),
409            ReflectionDepth::Meta => self.meta_reflect(conn, memory_contents)?,
410        };
411
412        Ok(Reflection {
413            id: 0,
414            content,
415            source_ids,
416            depth,
417            insights,
418            created_at: now,
419        })
420    }
421
422    // ------------------------------------------------------------------
423    // Private: Surface reflection
424    // ------------------------------------------------------------------
425
426    /// Surface: extract and summarise the most common nouns/content words.
427    fn surface_reflect(&self, memory_contents: &[(i64, &str)]) -> (String, Vec<String>) {
428        if memory_contents.is_empty() {
429            return (
430                "No memories provided for reflection.".to_string(),
431                Vec::new(),
432            );
433        }
434
435        // Collect all tokens, filter stopwords
436        let stopwords = &[
437            "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has",
438            "had", "do", "does", "did", "will", "would", "could", "should", "may", "might",
439            "shall", "can", "to", "of", "in", "for", "on", "with", "at", "by", "from", "and", "or",
440            "but", "if", "then", "that", "this", "it", "its", "i", "you", "we", "they", "he",
441            "she", "my", "your", "our", "their", "not", "no", "so",
442        ];
443
444        let mut freq: HashMap<String, usize> = HashMap::new();
445        for (_, content) in memory_contents {
446            for token in content.split_whitespace() {
447                let t = token
448                    .to_lowercase()
449                    .trim_matches(|c: char| !c.is_alphanumeric())
450                    .to_string();
451                if t.len() > 3 && !stopwords.contains(&t.as_str()) {
452                    *freq.entry(t).or_insert(0) += 1;
453                }
454            }
455        }
456
457        let mut sorted: Vec<(String, usize)> = freq.into_iter().collect();
458        sorted.sort_by(|a, b| b.1.cmp(&a.1));
459        let top_themes: Vec<String> = sorted.into_iter().take(5).map(|(w, _)| w).collect();
460
461        let insights: Vec<String> = top_themes
462            .iter()
463            .map(|t| format!("Key theme: {}", t))
464            .collect();
465
466        let content = if top_themes.is_empty() {
467            format!(
468                "Reflection over {} memories. No dominant themes detected.",
469                memory_contents.len()
470            )
471        } else {
472            format!(
473                "Reflection over {} memories. Key themes: {}.",
474                memory_contents.len(),
475                top_themes.join(", ")
476            )
477        };
478
479        (content, insights)
480    }
481
482    // ------------------------------------------------------------------
483    // Private: Analytical reflection
484    // ------------------------------------------------------------------
485
486    /// Analytical: sentiment trends, topic clusters, contradictions.
487    fn analytical_reflect(&self, memory_contents: &[(i64, &str)]) -> (String, Vec<String>) {
488        if memory_contents.is_empty() {
489            return (
490                "No memories provided for analytical reflection.".to_string(),
491                Vec::new(),
492            );
493        }
494
495        let mut pos_count = 0usize;
496        let mut neg_count = 0usize;
497        let mut mixed_count = 0usize;
498        let mut neutral_count = 0usize;
499        let mut total_score: f32 = 0.0;
500
501        let sentiments: Vec<Sentiment> = memory_contents
502            .iter()
503            .map(|(_, c)| self.analyzer.analyze(c))
504            .collect();
505
506        for s in &sentiments {
507            total_score += s.score;
508            match s.label {
509                SentimentLabel::Positive => pos_count += 1,
510                SentimentLabel::Negative => neg_count += 1,
511                SentimentLabel::Mixed => mixed_count += 1,
512                SentimentLabel::Neutral => neutral_count += 1,
513            }
514        }
515
516        let n = memory_contents.len();
517        let avg_score = total_score / n as f32;
518
519        let mut insights = Vec::new();
520
521        // Sentiment trend
522        let trend = if avg_score > 0.3 {
523            insights.push(format!(
524                "Overall sentiment is positive (avg score: {:.2})",
525                avg_score
526            ));
527            "positive"
528        } else if avg_score < -0.3 {
529            insights.push(format!(
530                "Overall sentiment is negative (avg score: {:.2})",
531                avg_score
532            ));
533            "negative"
534        } else {
535            insights.push(format!(
536                "Overall sentiment is neutral (avg score: {:.2})",
537                avg_score
538            ));
539            "neutral"
540        };
541
542        // Distribution insight
543        if pos_count > 0 || neg_count > 0 {
544            insights.push(format!(
545                "Distribution: {} positive, {} negative, {} mixed, {} neutral",
546                pos_count, neg_count, mixed_count, neutral_count
547            ));
548        }
549
550        // Contradiction detection
551        if pos_count > 0 && neg_count > 0 {
552            let ratio = pos_count.min(neg_count) as f32 / pos_count.max(neg_count) as f32;
553            if ratio > 0.4 {
554                insights.push(format!(
555                    "Contradictory signals detected: {} positive vs {} negative memories",
556                    pos_count, neg_count
557                ));
558            }
559        }
560
561        // Keyword frequency across all memories
562        let mut kw_freq: HashMap<String, usize> = HashMap::new();
563        for s in &sentiments {
564            for kw in &s.keywords {
565                *kw_freq.entry(kw.clone()).or_insert(0) += 1;
566            }
567        }
568        let mut top_kw: Vec<(String, usize)> = kw_freq.into_iter().collect();
569        top_kw.sort_by(|a, b| b.1.cmp(&a.1));
570        let top_keywords: Vec<String> = top_kw.into_iter().take(3).map(|(k, _)| k).collect();
571        if !top_keywords.is_empty() {
572            insights.push(format!(
573                "Frequent sentiment keywords: {}",
574                top_keywords.join(", ")
575            ));
576        }
577
578        let content = format!(
579            "Analytical reflection over {} memories. Overall {trend} tone (avg score: {:.2}). \
580             Positive: {pos_count}, Negative: {neg_count}, Mixed: {mixed_count}, Neutral: {neutral_count}.",
581            n,
582            avg_score,
583        );
584
585        (content, insights)
586    }
587
588    // ------------------------------------------------------------------
589    // Private: Meta reflection
590    // ------------------------------------------------------------------
591
592    /// Meta: reflects on existing saved reflections plus the supplied memories.
593    fn meta_reflect(
594        &self,
595        conn: &Connection,
596        memory_contents: &[(i64, &str)],
597    ) -> Result<(String, Vec<String>)> {
598        // Load recent saved reflections to incorporate
599        let prior = list_reflections(conn, None, 10)?;
600
601        let prior_count = prior.len();
602
603        // Analytical pass on current memories
604        let (analytical_content, mut insights) = self.analytical_reflect(memory_contents);
605
606        // Add meta-insights from prior reflections
607        if prior_count == 0 {
608            insights
609                .push("No prior reflections found; this is a first-order reflection.".to_string());
610        } else {
611            insights.push(format!(
612                "Built on {} prior reflections for meta-level synthesis.",
613                prior_count
614            ));
615
616            // Count depth distribution of prior reflections
617            let surface_count = prior
618                .iter()
619                .filter(|r| r.depth == ReflectionDepth::Surface)
620                .count();
621            let analytical_count = prior
622                .iter()
623                .filter(|r| r.depth == ReflectionDepth::Analytical)
624                .count();
625
626            if surface_count > 0 || analytical_count > 0 {
627                insights.push(format!(
628                    "Prior reflection depth breakdown: {} surface, {} analytical.",
629                    surface_count, analytical_count
630                ));
631            }
632
633            // Synthesise a recurring theme from prior reflection contents
634            let prior_text: String = prior
635                .iter()
636                .map(|r| r.content.as_str())
637                .collect::<Vec<_>>()
638                .join(" ");
639            let prior_sentiment = self.analyzer.analyze(&prior_text);
640            insights.push(format!(
641                "Aggregate prior reflection sentiment: {} (score: {:.2}).",
642                prior_sentiment.label.as_str(),
643                prior_sentiment.score
644            ));
645        }
646
647        let content = format!(
648            "Meta-reflection synthesising {} current memories with {} prior reflections. {}",
649            memory_contents.len(),
650            prior_count,
651            analytical_content,
652        );
653
654        Ok((content, insights))
655    }
656}
657
658impl Default for ReflectionEngine {
659    fn default() -> Self {
660        Self::new()
661    }
662}
663
664// =============================================================================
665// Storage helpers
666// =============================================================================
667
668/// Persist a [`Reflection`] and return its database-assigned id.
669pub fn save_reflection(conn: &Connection, reflection: &Reflection) -> Result<i64> {
670    let source_ids_json = serde_json::to_string(&reflection.source_ids)?;
671    let now = if reflection.created_at.is_empty() {
672        Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
673    } else {
674        reflection.created_at.clone()
675    };
676
677    conn.execute(
678        "INSERT INTO reflections (content, source_ids, depth, created_at) VALUES (?1, ?2, ?3, ?4)",
679        params![
680            reflection.content,
681            source_ids_json,
682            reflection.depth.as_str(),
683            now,
684        ],
685    )?;
686
687    Ok(conn.last_insert_rowid())
688}
689
690/// List persisted reflections, optionally filtered by depth.
691///
692/// `limit = 0` returns all rows (up to i64::MAX).
693pub fn list_reflections(
694    conn: &Connection,
695    depth: Option<ReflectionDepth>,
696    limit: usize,
697) -> Result<Vec<Reflection>> {
698    let effective_limit = if limit == 0 { i64::MAX } else { limit as i64 };
699
700    let rows: Vec<Reflection> = match depth {
701        Some(d) => {
702            let mut stmt = conn.prepare(
703                "SELECT id, content, source_ids, depth, created_at
704                 FROM reflections
705                 WHERE depth = ?1
706                 ORDER BY id DESC
707                 LIMIT ?2",
708            )?;
709            let collected = stmt
710                .query_map(params![d.as_str(), effective_limit], map_reflection_row)?
711                .collect::<std::result::Result<Vec<_>, _>>()?;
712            collected
713        }
714        None => {
715            let mut stmt = conn.prepare(
716                "SELECT id, content, source_ids, depth, created_at
717                 FROM reflections
718                 ORDER BY id DESC
719                 LIMIT ?1",
720            )?;
721            let collected = stmt
722                .query_map(params![effective_limit], map_reflection_row)?
723                .collect::<std::result::Result<Vec<_>, _>>()?;
724            collected
725        }
726    };
727
728    Ok(rows)
729}
730
731/// Build a sentiment timeline for memories in a workspace within a date range.
732///
733/// `from` and `to` are RFC3339 UTC strings used in a `BETWEEN` comparison.
734/// Returns one [`SentimentPoint`] per memory, ordered by `created_at` ascending.
735///
736/// Requires the standard `memories` table to be present in `conn`.
737pub fn sentiment_timeline(
738    conn: &Connection,
739    workspace: &str,
740    from: &str,
741    to: &str,
742) -> Result<Vec<SentimentPoint>> {
743    let mut stmt = conn.prepare(
744        "SELECT id, content, created_at
745         FROM memories
746         WHERE workspace = ?1
747           AND created_at BETWEEN ?2 AND ?3
748         ORDER BY created_at ASC",
749    )?;
750
751    let analyzer = SentimentAnalyzer::new();
752
753    let points: Vec<SentimentPoint> = stmt
754        .query_map(params![workspace, from, to], |row| {
755            let id: i64 = row.get(0)?;
756            let content: String = row.get(1)?;
757            let timestamp: String = row.get(2)?;
758            Ok((id, content, timestamp))
759        })?
760        .filter_map(|r| r.ok())
761        .map(|(id, content, timestamp)| {
762            let sentiment = analyzer.analyze(&content);
763            SentimentPoint {
764                timestamp,
765                score: sentiment.score,
766                memory_id: id,
767            }
768        })
769        .collect();
770
771    Ok(points)
772}
773
774/// Map a rusqlite row from the `reflections` table to a [`Reflection`].
775fn map_reflection_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reflection> {
776    let id: i64 = row.get(0)?;
777    let content: String = row.get(1)?;
778    let source_ids_json: String = row.get(2)?;
779    let depth_str: String = row.get(3)?;
780    let created_at: String = row.get(4)?;
781
782    let source_ids: Vec<i64> = serde_json::from_str(&source_ids_json).unwrap_or_default();
783
784    let depth = match depth_str.as_str() {
785        "surface" => ReflectionDepth::Surface,
786        "analytical" => ReflectionDepth::Analytical,
787        "meta" => ReflectionDepth::Meta,
788        _ => ReflectionDepth::Surface,
789    };
790
791    Ok(Reflection {
792        id,
793        content,
794        source_ids,
795        depth,
796        insights: Vec::new(), // insights are not persisted; regenerated on demand
797        created_at,
798    })
799}
800
801// =============================================================================
802// Tests
803// =============================================================================
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use rusqlite::Connection;
809
810    fn in_memory_conn() -> Connection {
811        let conn = Connection::open_in_memory().expect("in-memory db");
812        conn.execute_batch(CREATE_REFLECTIONS_TABLE)
813            .expect("create reflections table");
814        conn
815    }
816
817    fn memories_table(conn: &Connection) {
818        conn.execute_batch(
819            "CREATE TABLE IF NOT EXISTS memories (
820                id          INTEGER PRIMARY KEY AUTOINCREMENT,
821                content     TEXT    NOT NULL,
822                workspace   TEXT    NOT NULL DEFAULT 'default',
823                created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
824            );",
825        )
826        .expect("create memories table");
827    }
828
829    fn analyzer() -> SentimentAnalyzer {
830        SentimentAnalyzer::new()
831    }
832
833    // -----------------------------------------------------------------------
834    // 1. Positive sentiment
835    // -----------------------------------------------------------------------
836    #[test]
837    fn test_positive_sentiment() {
838        let s = analyzer().analyze("This is a great and amazing product");
839        assert_eq!(s.label, SentimentLabel::Positive);
840        assert!(s.score > 0.0, "score should be positive, got {}", s.score);
841        assert!(
842            s.keywords.contains(&"great".to_string())
843                || s.keywords.contains(&"amazing".to_string())
844        );
845    }
846
847    // -----------------------------------------------------------------------
848    // 2. Negative sentiment
849    // -----------------------------------------------------------------------
850    #[test]
851    fn test_negative_sentiment() {
852        let s = analyzer().analyze("The software is broken and has terrible bugs");
853        assert_eq!(s.label, SentimentLabel::Negative);
854        assert!(s.score < 0.0, "score should be negative, got {}", s.score);
855        assert!(
856            s.keywords.contains(&"broken".to_string())
857                || s.keywords.contains(&"terrible".to_string())
858                || s.keywords.contains(&"bugs".to_string())
859        );
860    }
861
862    // -----------------------------------------------------------------------
863    // 3. Negation flipping
864    // -----------------------------------------------------------------------
865    #[test]
866    fn test_negation_flips_positive() {
867        let positive = analyzer().analyze("great work here");
868        let negated = analyzer().analyze("not great work here");
869        // Without negation: positive; with negation: should flip toward negative
870        assert!(
871            positive.score > negated.score,
872            "negation should reduce score: positive={}, negated={}",
873            positive.score,
874            negated.score
875        );
876    }
877
878    #[test]
879    fn test_negation_flips_negative() {
880        let negative = analyzer().analyze("this is terrible");
881        let negated = analyzer().analyze("this is not terrible");
882        // Without negation: negative; with negation: should flip toward positive
883        assert!(
884            negated.score > negative.score,
885            "negation of negative word should increase score: negative={}, negated={}",
886            negative.score,
887            negated.score
888        );
889    }
890
891    // -----------------------------------------------------------------------
892    // 5. Intensifiers boost magnitude
893    // -----------------------------------------------------------------------
894    #[test]
895    fn test_intensifiers_boost_magnitude() {
896        let base = analyzer().analyze("good result");
897        let intensified = analyzer().analyze("very good result");
898        // Intensified should have a higher raw score; after normalisation the
899        // score may be clamped, but the label should still be positive.
900        assert_eq!(base.label, SentimentLabel::Positive);
901        assert_eq!(intensified.label, SentimentLabel::Positive);
902        assert!(
903            intensified.score >= base.score,
904            "intensifier should not decrease score: base={}, intensified={}",
905            base.score,
906            intensified.score
907        );
908    }
909
910    // -----------------------------------------------------------------------
911    // 4. Mixed sentiment
912    // -----------------------------------------------------------------------
913    #[test]
914    fn test_mixed_sentiment() {
915        // Balanced mix of positive and negative words
916        let s = analyzer().analyze("great performance but terrible stability and broken error");
917        // Either Mixed or the dominant one — accept Mixed or Positive since "great" appears
918        assert!(
919            matches!(
920                s.label,
921                SentimentLabel::Mixed | SentimentLabel::Positive | SentimentLabel::Negative
922            ),
923            "unexpected label: {:?}",
924            s.label
925        );
926        // Both polarities must be represented in keywords
927        let has_positive = s
928            .keywords
929            .iter()
930            .any(|k| POSITIVE_WORDS.contains(&k.as_str()));
931        let has_negative = s
932            .keywords
933            .iter()
934            .any(|k| NEGATIVE_WORDS.contains(&k.as_str()));
935        assert!(has_positive, "expected positive keywords in mixed text");
936        assert!(has_negative, "expected negative keywords in mixed text");
937    }
938
939    // -----------------------------------------------------------------------
940    // 6. Empty text
941    // -----------------------------------------------------------------------
942    #[test]
943    fn test_empty_text() {
944        let s = analyzer().analyze("");
945        assert_eq!(s.label, SentimentLabel::Neutral);
946        assert_eq!(s.score, 0.0);
947        assert!(s.keywords.is_empty());
948    }
949
950    #[test]
951    fn test_whitespace_only_text() {
952        let s = analyzer().analyze("   \t\n  ");
953        assert_eq!(s.label, SentimentLabel::Neutral);
954        assert_eq!(s.score, 0.0);
955    }
956
957    // -----------------------------------------------------------------------
958    // 7. Surface reflection
959    // -----------------------------------------------------------------------
960    #[test]
961    fn test_reflection_surface() {
962        let conn = in_memory_conn();
963        let engine = ReflectionEngine::new();
964        let memories = vec![
965            (1i64, "memory performance is really fast and scalable"),
966            (2i64, "memory performance tests look good"),
967        ];
968        let reflection = engine
969            .create_reflection(&conn, &memories, ReflectionDepth::Surface)
970            .expect("surface reflection should succeed");
971
972        assert!(!reflection.content.is_empty());
973        assert_eq!(reflection.depth, ReflectionDepth::Surface);
974        assert_eq!(reflection.source_ids, vec![1, 2]);
975        assert!(
976            !reflection.insights.is_empty(),
977            "surface reflection should produce insights"
978        );
979        // Content should mention key theme words from the memories
980        let content_lower = reflection.content.to_lowercase();
981        assert!(
982            content_lower.contains("theme") || content_lower.contains("memories"),
983            "unexpected surface content: {}",
984            reflection.content
985        );
986    }
987
988    // -----------------------------------------------------------------------
989    // 8. Analytical reflection
990    // -----------------------------------------------------------------------
991    #[test]
992    fn test_reflection_analytical() {
993        let conn = in_memory_conn();
994        let engine = ReflectionEngine::new();
995        let memories = vec![
996            (1i64, "the new feature is excellent and robust"),
997            (2i64, "there is a terrible bug and regression in production"),
998            (3i64, "deployment went smooth and stable"),
999        ];
1000        let reflection = engine
1001            .create_reflection(&conn, &memories, ReflectionDepth::Analytical)
1002            .expect("analytical reflection should succeed");
1003
1004        assert!(!reflection.content.is_empty());
1005        assert_eq!(reflection.depth, ReflectionDepth::Analytical);
1006        // Should detect contradictory signals
1007        let has_contradiction = reflection
1008            .insights
1009            .iter()
1010            .any(|i| i.contains("Contradict") || i.contains("positive") || i.contains("negative"));
1011        assert!(
1012            has_contradiction,
1013            "analytical reflection should detect sentiment signals"
1014        );
1015    }
1016
1017    // -----------------------------------------------------------------------
1018    // 9. Sentiment timeline
1019    // -----------------------------------------------------------------------
1020    #[test]
1021    fn test_sentiment_timeline() {
1022        let conn = in_memory_conn();
1023        memories_table(&conn);
1024
1025        conn.execute_batch(
1026            "INSERT INTO memories (id, content, workspace, created_at) VALUES
1027               (1, 'great day today excellent work', 'test', '2025-01-01T10:00:00Z'),
1028               (2, 'terrible bug crash broken', 'test', '2025-01-02T10:00:00Z'),
1029               (3, 'stable and reliable release', 'test', '2025-01-03T10:00:00Z');",
1030        )
1031        .expect("insert memories");
1032
1033        let timeline = sentiment_timeline(
1034            &conn,
1035            "test",
1036            "2025-01-01T00:00:00Z",
1037            "2025-01-03T23:59:59Z",
1038        )
1039        .expect("timeline should succeed");
1040
1041        assert_eq!(timeline.len(), 3, "expected 3 sentiment points");
1042        assert!(timeline[0].score > 0.0, "first memory should be positive");
1043        assert!(timeline[1].score < 0.0, "second memory should be negative");
1044        assert!(timeline[2].score > 0.0, "third memory should be positive");
1045
1046        // Verify IDs and ordering
1047        assert_eq!(timeline[0].memory_id, 1);
1048        assert_eq!(timeline[1].memory_id, 2);
1049        assert_eq!(timeline[2].memory_id, 3);
1050    }
1051
1052    // -----------------------------------------------------------------------
1053    // 10. save_reflection and list_reflections
1054    // -----------------------------------------------------------------------
1055    #[test]
1056    fn test_save_and_list_reflections() {
1057        let conn = in_memory_conn();
1058        let engine = ReflectionEngine::new();
1059
1060        let memories = vec![(10i64, "smooth and fast deployment was successful")];
1061        let mut reflection = engine
1062            .create_reflection(&conn, &memories, ReflectionDepth::Surface)
1063            .expect("create reflection");
1064
1065        let id = save_reflection(&conn, &reflection).expect("save reflection");
1066        assert!(id > 0, "saved id should be positive");
1067        reflection.id = id;
1068
1069        let all = list_reflections(&conn, None, 10).expect("list reflections");
1070        assert_eq!(all.len(), 1);
1071        assert_eq!(all[0].id, id);
1072        assert_eq!(all[0].depth, ReflectionDepth::Surface);
1073
1074        let surface_only =
1075            list_reflections(&conn, Some(ReflectionDepth::Surface), 10).expect("list surface");
1076        assert_eq!(surface_only.len(), 1);
1077
1078        let analytical_only = list_reflections(&conn, Some(ReflectionDepth::Analytical), 10)
1079            .expect("list analytical");
1080        assert!(analytical_only.is_empty());
1081    }
1082}