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}