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