Skip to main content

pulsedb/experience/
types.rs

1//! Type definitions for experiences.
2//!
3//! An **experience** is the core data type in PulseDB — a unit of learned knowledge
4//! that agents share through collectives. Each experience has content, an embedding
5//! vector for semantic search, a rich type, and metadata.
6//!
7//! # Type Hierarchy
8//!
9//! ```text
10//! ExperienceType (rich, with associated data)
11//!     ↓ type_tag()
12//! ExperienceTypeTag (compact 1-byte discriminant for index keys)
13//! ```
14
15use serde::{Deserialize, Serialize};
16
17use crate::storage::schema::ExperienceTypeTag;
18use crate::types::{AgentId, CollectiveId, ExperienceId, TaskId, Timestamp};
19
20// ============================================================================
21// Severity
22// ============================================================================
23
24/// Severity level for difficulty experiences.
25///
26/// Used as associated data in [`ExperienceType::Difficulty`] to indicate
27/// how impactful a problem was.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub enum Severity {
30    /// Minor impact, easily worked around.
31    Low,
32    /// Noticeable impact, workaround available.
33    Medium,
34    /// Significant impact, blocks progress.
35    High,
36    /// Showstopper, must be resolved immediately.
37    Critical,
38}
39
40// ============================================================================
41// ExperienceType — Rich enum with 9 variants (ADR-004)
42// ============================================================================
43
44/// Rich experience type with associated data per variant.
45///
46/// This is the full type stored in the experience record. For index keys,
47/// use [`type_tag()`](Self::type_tag) to get the compact
48/// [`ExperienceTypeTag`] discriminant.
49///
50/// # Variants
51///
52/// Each variant carries structured data specific to that kind of experience:
53/// - **Difficulty** — A problem the agent encountered
54/// - **Solution** — A fix for a problem, optionally linked to a Difficulty
55/// - **ErrorPattern** — A reusable error signature with fix and prevention
56/// - **SuccessPattern** — A proven approach with quality rating
57/// - **UserPreference** — A user preference with strength
58/// - **ArchitecturalDecision** — A design decision with rationale
59/// - **TechInsight** — Technical knowledge about a technology
60/// - **Fact** — A verified factual statement with source
61/// - **Generic** — Catch-all for uncategorized experiences
62#[derive(Clone, Debug, Serialize, Deserialize)]
63pub enum ExperienceType {
64    /// Problem encountered by the agent.
65    Difficulty {
66        /// What the problem is.
67        description: String,
68        /// How severe the problem is.
69        severity: Severity,
70    },
71
72    /// Fix for a problem, optionally linked to a Difficulty experience.
73    Solution {
74        /// Reference to the Difficulty experience this solves, if any.
75        problem_ref: Option<ExperienceId>,
76        /// The approach taken to solve the problem.
77        approach: String,
78        /// Whether the solution worked.
79        worked: bool,
80    },
81
82    /// Reusable error signature with fix and prevention strategy.
83    ErrorPattern {
84        /// The error signature (e.g., error code, message pattern).
85        signature: String,
86        /// How to fix occurrences of this error.
87        fix: String,
88        /// How to prevent this error from occurring.
89        prevention: String,
90    },
91
92    /// Proven approach with quality rating (0.0–1.0).
93    SuccessPattern {
94        /// The type of task this pattern applies to.
95        task_type: String,
96        /// The approach that works.
97        approach: String,
98        /// Quality rating of the outcome (0.0–1.0).
99        quality: f32,
100    },
101
102    /// User preference with strength (0.0–1.0).
103    UserPreference {
104        /// The preference category (e.g., "style", "tooling").
105        category: String,
106        /// The specific preference.
107        preference: String,
108        /// How strongly the user feels about this (0.0–1.0).
109        strength: f32,
110    },
111
112    /// Design decision with rationale.
113    ArchitecturalDecision {
114        /// The decision made.
115        decision: String,
116        /// Why this decision was made.
117        rationale: String,
118    },
119
120    /// Technical knowledge about a specific technology.
121    TechInsight {
122        /// The technology this insight is about.
123        technology: String,
124        /// The insight or knowledge.
125        insight: String,
126    },
127
128    /// Verified factual statement with source attribution.
129    Fact {
130        /// The factual statement.
131        statement: String,
132        /// Where this fact was verified.
133        source: String,
134    },
135
136    /// Catch-all for uncategorized experiences.
137    Generic {
138        /// Optional category label.
139        category: Option<String>,
140    },
141}
142
143impl ExperienceType {
144    /// Returns the compact [`ExperienceTypeTag`] for use in index keys.
145    ///
146    /// This bridges the rich type (with data) to the 1-byte discriminant
147    /// stored in secondary index keys.
148    pub fn type_tag(&self) -> ExperienceTypeTag {
149        match self {
150            Self::Difficulty { .. } => ExperienceTypeTag::Difficulty,
151            Self::Solution { .. } => ExperienceTypeTag::Solution,
152            Self::ErrorPattern { .. } => ExperienceTypeTag::ErrorPattern,
153            Self::SuccessPattern { .. } => ExperienceTypeTag::SuccessPattern,
154            Self::UserPreference { .. } => ExperienceTypeTag::UserPreference,
155            Self::ArchitecturalDecision { .. } => ExperienceTypeTag::ArchitecturalDecision,
156            Self::TechInsight { .. } => ExperienceTypeTag::TechInsight,
157            Self::Fact { .. } => ExperienceTypeTag::Fact,
158            Self::Generic { .. } => ExperienceTypeTag::Generic,
159        }
160    }
161}
162
163impl Default for ExperienceType {
164    fn default() -> Self {
165        Self::Generic { category: None }
166    }
167}
168
169// ============================================================================
170// Experience — The full stored record
171// ============================================================================
172
173/// A stored experience — the core data type in PulseDB.
174///
175/// Experiences are agent-learned knowledge units stored in collectives.
176/// Each experience has content, a semantic embedding for vector search,
177/// a rich type, and metadata for filtering and ranking.
178///
179/// # Serialization Note
180///
181/// The `embedding` field is marked `#[serde(skip)]` because embeddings are
182/// stored in a separate `EMBEDDINGS_TABLE` for performance. The storage
183/// layer reconstitutes the full struct by joining both tables on read.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct Experience {
186    /// Unique identifier (UUID v7, time-ordered).
187    pub id: ExperienceId,
188
189    /// The collective this experience belongs to.
190    pub collective_id: CollectiveId,
191
192    /// The experience content (text). Immutable after creation.
193    pub content: String,
194
195    /// Semantic embedding vector. Immutable after creation.
196    ///
197    /// Stored separately in EMBEDDINGS_TABLE; skipped during bincode
198    /// serialization of the main experience record.
199    #[serde(skip)]
200    pub embedding: Vec<f32>,
201
202    /// Rich experience type with associated data.
203    pub experience_type: ExperienceType,
204
205    /// Importance score (0.0–1.0). Higher = more important.
206    pub importance: f32,
207
208    /// Confidence score (0.0–1.0). Higher = more confident.
209    pub confidence: f32,
210
211    /// Number of times this experience has been applied/reinforced.
212    pub applications: u32,
213
214    /// Domain tags for categorical filtering (e.g., ["rust", "async"]).
215    pub domain: Vec<String>,
216
217    /// Related source file paths.
218    pub related_files: Vec<String>,
219
220    /// The agent that created this experience.
221    pub source_agent: AgentId,
222
223    /// Optional task context where this experience was created.
224    pub source_task: Option<TaskId>,
225
226    /// When this experience was recorded.
227    pub timestamp: Timestamp,
228
229    /// Whether this experience is archived (soft-deleted).
230    ///
231    /// Archived experiences are excluded from search results but remain
232    /// in storage and can be restored via `unarchive_experience()`.
233    pub archived: bool,
234}
235
236// ============================================================================
237// NewExperience — Input for record_experience()
238// ============================================================================
239
240/// Input for creating a new experience via [`PulseDB::record_experience()`](crate::PulseDB).
241///
242/// Only the mutable fields are set here. The `id`, `timestamp`, `applications`,
243/// and `archived` fields are set automatically by the storage layer.
244///
245/// # Embedding
246///
247/// - **External provider**: `embedding` is required (must be `Some`)
248/// - **Builtin provider**: `embedding` is optional; if `None`, PulseDB generates it
249#[derive(Clone, Debug)]
250pub struct NewExperience {
251    /// The collective to store this experience in.
252    pub collective_id: CollectiveId,
253
254    /// The experience content (text).
255    pub content: String,
256
257    /// Rich experience type.
258    pub experience_type: ExperienceType,
259
260    /// Pre-computed embedding vector. Required for External provider.
261    pub embedding: Option<Vec<f32>>,
262
263    /// Importance score (0.0–1.0).
264    pub importance: f32,
265
266    /// Confidence score (0.0–1.0).
267    pub confidence: f32,
268
269    /// Domain tags for categorical filtering.
270    pub domain: Vec<String>,
271
272    /// Related source file paths.
273    pub related_files: Vec<String>,
274
275    /// The agent creating this experience.
276    pub source_agent: AgentId,
277
278    /// Optional task context.
279    pub source_task: Option<TaskId>,
280}
281
282impl Default for NewExperience {
283    fn default() -> Self {
284        Self {
285            collective_id: CollectiveId::nil(),
286            content: String::new(),
287            experience_type: ExperienceType::default(),
288            embedding: None,
289            importance: 0.5,
290            confidence: 0.5,
291            domain: Vec::new(),
292            related_files: Vec::new(),
293            source_agent: AgentId::new("anonymous"),
294            source_task: None,
295        }
296    }
297}
298
299// ============================================================================
300// ExperienceUpdate — Partial update for mutable fields
301// ============================================================================
302
303/// Partial update for an experience's mutable fields.
304///
305/// Only fields set to `Some(...)` will be updated. Content and embedding
306/// are immutable — create a new experience if content changes.
307#[derive(Clone, Debug, Default)]
308pub struct ExperienceUpdate {
309    /// New importance score (0.0–1.0).
310    pub importance: Option<f32>,
311
312    /// New confidence score (0.0–1.0).
313    pub confidence: Option<f32>,
314
315    /// Replace domain tags entirely.
316    pub domain: Option<Vec<String>>,
317
318    /// Replace related files entirely.
319    pub related_files: Option<Vec<String>>,
320
321    /// Set archived status (used internally by archive/unarchive).
322    pub archived: Option<bool>,
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    // ====================================================================
330    // Severity tests
331    // ====================================================================
332
333    #[test]
334    fn test_severity_bincode_roundtrip() {
335        for severity in [
336            Severity::Low,
337            Severity::Medium,
338            Severity::High,
339            Severity::Critical,
340        ] {
341            let bytes = bincode::serialize(&severity).unwrap();
342            let restored: Severity = bincode::deserialize(&bytes).unwrap();
343            assert_eq!(severity, restored);
344        }
345    }
346
347    // ====================================================================
348    // ExperienceType tests
349    // ====================================================================
350
351    #[test]
352    fn test_experience_type_default() {
353        let et = ExperienceType::default();
354        assert!(matches!(et, ExperienceType::Generic { category: None }));
355    }
356
357    #[test]
358    fn test_experience_type_tag_mapping() {
359        let cases: Vec<(ExperienceType, ExperienceTypeTag)> = vec![
360            (
361                ExperienceType::Difficulty {
362                    description: "test".into(),
363                    severity: Severity::High,
364                },
365                ExperienceTypeTag::Difficulty,
366            ),
367            (
368                ExperienceType::Solution {
369                    problem_ref: None,
370                    approach: "test".into(),
371                    worked: true,
372                },
373                ExperienceTypeTag::Solution,
374            ),
375            (
376                ExperienceType::ErrorPattern {
377                    signature: "test".into(),
378                    fix: "test".into(),
379                    prevention: "test".into(),
380                },
381                ExperienceTypeTag::ErrorPattern,
382            ),
383            (
384                ExperienceType::SuccessPattern {
385                    task_type: "test".into(),
386                    approach: "test".into(),
387                    quality: 0.9,
388                },
389                ExperienceTypeTag::SuccessPattern,
390            ),
391            (
392                ExperienceType::UserPreference {
393                    category: "test".into(),
394                    preference: "test".into(),
395                    strength: 0.8,
396                },
397                ExperienceTypeTag::UserPreference,
398            ),
399            (
400                ExperienceType::ArchitecturalDecision {
401                    decision: "test".into(),
402                    rationale: "test".into(),
403                },
404                ExperienceTypeTag::ArchitecturalDecision,
405            ),
406            (
407                ExperienceType::TechInsight {
408                    technology: "test".into(),
409                    insight: "test".into(),
410                },
411                ExperienceTypeTag::TechInsight,
412            ),
413            (
414                ExperienceType::Fact {
415                    statement: "test".into(),
416                    source: "test".into(),
417                },
418                ExperienceTypeTag::Fact,
419            ),
420            (
421                ExperienceType::Generic {
422                    category: Some("test".into()),
423                },
424                ExperienceTypeTag::Generic,
425            ),
426        ];
427
428        for (experience_type, expected_tag) in cases {
429            assert_eq!(
430                experience_type.type_tag(),
431                expected_tag,
432                "Tag mismatch for {:?}",
433                experience_type,
434            );
435        }
436    }
437
438    #[test]
439    fn test_experience_type_bincode_roundtrip_all_variants() {
440        let variants = vec![
441            ExperienceType::Difficulty {
442                description: "compile error".into(),
443                severity: Severity::High,
444            },
445            ExperienceType::Solution {
446                problem_ref: Some(ExperienceId::new()),
447                approach: "added lifetime annotation".into(),
448                worked: true,
449            },
450            ExperienceType::ErrorPattern {
451                signature: "E0308 mismatched types".into(),
452                fix: "check return type".into(),
453                prevention: "use clippy".into(),
454            },
455            ExperienceType::SuccessPattern {
456                task_type: "refactoring".into(),
457                approach: "extract method".into(),
458                quality: 0.95,
459            },
460            ExperienceType::UserPreference {
461                category: "style".into(),
462                preference: "snake_case".into(),
463                strength: 0.9,
464            },
465            ExperienceType::ArchitecturalDecision {
466                decision: "use redb over SQLite".into(),
467                rationale: "pure Rust, ACID, no FFI".into(),
468            },
469            ExperienceType::TechInsight {
470                technology: "tokio".into(),
471                insight: "spawn_blocking for CPU-bound work".into(),
472            },
473            ExperienceType::Fact {
474                statement: "redb uses shadow paging".into(),
475                source: "redb docs".into(),
476            },
477            ExperienceType::Generic { category: None },
478        ];
479
480        for variant in variants {
481            let bytes = bincode::serialize(&variant).unwrap();
482            let restored: ExperienceType = bincode::deserialize(&bytes).unwrap();
483            // Compare tags as a proxy (associated data is different types per variant)
484            assert_eq!(variant.type_tag(), restored.type_tag());
485        }
486    }
487
488    // ====================================================================
489    // Experience tests
490    // ====================================================================
491
492    #[test]
493    fn test_experience_bincode_roundtrip() {
494        let exp = Experience {
495            id: ExperienceId::new(),
496            collective_id: CollectiveId::new(),
497            content: "Test experience content".into(),
498            embedding: vec![0.1, 0.2, 0.3], // will be skipped by serde
499            experience_type: ExperienceType::Fact {
500                statement: "Rust is memory-safe".into(),
501                source: "docs".into(),
502            },
503            importance: 0.8,
504            confidence: 0.9,
505            applications: 5,
506            domain: vec!["rust".into(), "safety".into()],
507            related_files: vec!["src/main.rs".into()],
508            source_agent: AgentId::new("agent-1"),
509            source_task: Some(TaskId::new("task-42")),
510            timestamp: Timestamp::now(),
511            archived: false,
512        };
513
514        let bytes = bincode::serialize(&exp).unwrap();
515        let restored: Experience = bincode::deserialize(&bytes).unwrap();
516
517        assert_eq!(exp.id, restored.id);
518        assert_eq!(exp.collective_id, restored.collective_id);
519        assert_eq!(exp.content, restored.content);
520        // Embedding is skipped — restored should be empty
521        assert!(restored.embedding.is_empty());
522        assert_eq!(
523            exp.experience_type.type_tag(),
524            restored.experience_type.type_tag()
525        );
526        assert_eq!(exp.importance, restored.importance);
527        assert_eq!(exp.confidence, restored.confidence);
528        assert_eq!(exp.applications, restored.applications);
529        assert_eq!(exp.domain, restored.domain);
530        assert_eq!(exp.related_files, restored.related_files);
531        assert_eq!(exp.source_agent, restored.source_agent);
532        assert_eq!(exp.source_task, restored.source_task);
533        assert_eq!(exp.timestamp, restored.timestamp);
534        assert_eq!(exp.archived, restored.archived);
535    }
536
537    #[test]
538    fn test_experience_embedding_skipped_in_serialization() {
539        let exp = Experience {
540            id: ExperienceId::new(),
541            collective_id: CollectiveId::new(),
542            content: "test".into(),
543            embedding: vec![1.0; 384], // 384 floats = 1,536 bytes
544            experience_type: ExperienceType::default(),
545            importance: 0.5,
546            confidence: 0.5,
547            applications: 0,
548            domain: vec![],
549            related_files: vec![],
550            source_agent: AgentId::new("a"),
551            source_task: None,
552            timestamp: Timestamp::now(),
553            archived: false,
554        };
555
556        let bytes = bincode::serialize(&exp).unwrap();
557        // If embedding were included, size would be > 1,536 bytes.
558        // With skip, it should be much smaller.
559        assert!(
560            bytes.len() < 500,
561            "Serialized size {} suggests embedding was not skipped",
562            bytes.len()
563        );
564    }
565
566    // ====================================================================
567    // NewExperience tests
568    // ====================================================================
569
570    #[test]
571    fn test_new_experience_default() {
572        let ne = NewExperience::default();
573        assert_eq!(ne.collective_id, CollectiveId::nil());
574        assert!(ne.content.is_empty());
575        assert!(matches!(
576            ne.experience_type,
577            ExperienceType::Generic { category: None }
578        ));
579        assert!(ne.embedding.is_none());
580        assert_eq!(ne.importance, 0.5);
581        assert_eq!(ne.confidence, 0.5);
582        assert!(ne.domain.is_empty());
583        assert!(ne.related_files.is_empty());
584        assert_eq!(ne.source_agent.as_str(), "anonymous");
585        assert!(ne.source_task.is_none());
586    }
587
588    // ====================================================================
589    // ExperienceUpdate tests
590    // ====================================================================
591
592    #[test]
593    fn test_experience_update_default() {
594        let update = ExperienceUpdate::default();
595        assert!(update.importance.is_none());
596        assert!(update.confidence.is_none());
597        assert!(update.domain.is_none());
598        assert!(update.related_files.is_none());
599        assert!(update.archived.is_none());
600    }
601}