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}