pulsedb/storage/schema.rs
1//! Database schema definitions and versioning.
2//!
3//! This module defines the table structure for the redb storage engine.
4//! All table definitions are compile-time constants to ensure consistency.
5//!
6//! # Schema Versioning
7//!
8//! The schema version is stored in the metadata table. When opening an
9//! existing database, we check the version and fail if it doesn't match.
10//! Migration support will be added in a future release.
11//!
12//! # Table Layout
13//!
14//! ```text
15//! ┌─────────────────────────────────────────────────────────────┐
16//! │ METADATA_TABLE │
17//! │ Key: &str │
18//! │ Value: &[u8] (JSON for human-readable, bincode for data) │
19//! │ Entries: "db_metadata" -> DatabaseMetadata │
20//! └─────────────────────────────────────────────────────────────┘
21//!
22//! ┌─────────────────────────────────────────────────────────────┐
23//! │ COLLECTIVES_TABLE │
24//! │ Key: &[u8; 16] (CollectiveId as UUID bytes) │
25//! │ Value: &[u8] (bincode-serialized Collective) │
26//! └─────────────────────────────────────────────────────────────┘
27//!
28//! ┌─────────────────────────────────────────────────────────────┐
29//! │ EXPERIENCES_TABLE │
30//! │ Key: &[u8; 16] (ExperienceId as UUID bytes) │
31//! │ Value: &[u8] (bincode-serialized Experience) │
32//! └─────────────────────────────────────────────────────────────┘
33//! ```
34
35use redb::{MultimapTableDefinition, TableDefinition};
36use serde::{Deserialize, Serialize};
37
38use crate::config::EmbeddingDimension;
39use crate::types::Timestamp;
40
41/// Current schema version.
42///
43/// Increment this when making breaking changes to the schema.
44/// The database will refuse to open if versions don't match.
45pub const SCHEMA_VERSION: u32 = 1;
46
47/// Maximum content size in bytes (100 KB).
48pub const MAX_CONTENT_SIZE: usize = 100 * 1024;
49
50/// Maximum number of domain tags per experience.
51pub const MAX_DOMAIN_TAGS: usize = 50;
52
53/// Maximum length of a single domain tag.
54pub const MAX_TAG_LENGTH: usize = 100;
55
56/// Maximum number of source files per experience.
57pub const MAX_SOURCE_FILES: usize = 100;
58
59/// Maximum length of a single source file path.
60pub const MAX_FILE_PATH_LENGTH: usize = 500;
61
62/// Maximum length of a source agent identifier.
63pub const MAX_SOURCE_AGENT_LENGTH: usize = 256;
64
65/// Maximum relation metadata size in bytes (10 KB).
66pub const MAX_RELATION_METADATA_SIZE: usize = 10 * 1024;
67
68/// Maximum insight content size in bytes (50 KB).
69pub const MAX_INSIGHT_CONTENT_SIZE: usize = 50 * 1024;
70
71/// Maximum number of source experiences per insight.
72pub const MAX_INSIGHT_SOURCES: usize = 100;
73
74/// Maximum agent ID length in bytes.
75///
76/// Agent IDs are UTF-8 strings identifying a specific AI agent instance.
77/// 255 bytes is generous for identifiers like "claude-opus-4" or UUIDs.
78pub const MAX_ACTIVITY_AGENT_ID_LENGTH: usize = 255;
79
80/// Maximum size for activity optional fields (current_task, context_summary) in bytes (1 KB).
81///
82/// These fields are short descriptions, not full content — 1KB is sufficient
83/// for a task name or brief context summary.
84pub const MAX_ACTIVITY_FIELD_SIZE: usize = 1024;
85
86// ============================================================================
87// Table Definitions
88// ============================================================================
89
90/// Metadata table for database-level information.
91///
92/// Stores schema version, creation time, and other database-wide settings.
93/// Key is a string identifier, value is serialized data.
94pub const METADATA_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("metadata");
95
96/// Collectives table.
97///
98/// Key: CollectiveId as 16-byte UUID
99/// Value: bincode-serialized Collective struct
100pub const COLLECTIVES_TABLE: TableDefinition<&[u8; 16], &[u8]> =
101 TableDefinition::new("collectives");
102
103/// Experiences table.
104///
105/// Key: ExperienceId as 16-byte UUID
106/// Value: bincode-serialized Experience struct (without embedding)
107pub const EXPERIENCES_TABLE: TableDefinition<&[u8; 16], &[u8]> =
108 TableDefinition::new("experiences");
109
110/// Index: Experiences by collective and timestamp.
111///
112/// Enables efficient queries like "recent experiences in collective X".
113/// Key: CollectiveId as 16-byte UUID
114/// Value (multimap): (Timestamp big-endian 8 bytes, ExperienceId 16 bytes) = 24 bytes
115///
116/// Using a multimap allows multiple experiences per collective. Values are
117/// sorted lexicographically, so big-endian timestamps ensure time ordering.
118pub const EXPERIENCES_BY_COLLECTIVE_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 24]> =
119 MultimapTableDefinition::new("experiences_by_collective");
120
121/// Index: Experiences by collective and type.
122///
123/// Enables efficient queries like "all ErrorPattern experiences in collective X".
124/// Key: (CollectiveId bytes, ExperienceTypeTag byte) = 17 bytes
125/// Value: ExperienceId as 16-byte UUID
126///
127/// Using a multimap allows multiple experiences of the same type.
128pub const EXPERIENCES_BY_TYPE_TABLE: MultimapTableDefinition<&[u8; 17], &[u8; 16]> =
129 MultimapTableDefinition::new("experiences_by_type");
130
131/// Embeddings table.
132///
133/// Stored separately from experiences to keep the main table compact.
134/// Key: ExperienceId as 16-byte UUID
135/// Value: raw f32 bytes (dimension * 4 bytes)
136pub const EMBEDDINGS_TABLE: TableDefinition<&[u8; 16], &[u8]> = TableDefinition::new("embeddings");
137
138// ============================================================================
139// Relation Tables (E3-S01)
140// ============================================================================
141
142/// Relations table.
143///
144/// Primary storage for experience relations.
145/// Key: RelationId as 16-byte UUID
146/// Value: bincode-serialized ExperienceRelation struct
147pub const RELATIONS_TABLE: TableDefinition<&[u8; 16], &[u8]> = TableDefinition::new("relations");
148
149/// Index: Relations by source experience.
150///
151/// Enables efficient queries like "find all outgoing relations from experience X".
152/// Key: ExperienceId (source) as 16-byte UUID
153/// Value (multimap): RelationId as 16-byte UUID
154///
155/// Multiple relations per source experience. Iterate values with
156/// `table.get(source_id)?` to find all outgoing relation IDs.
157pub const RELATIONS_BY_SOURCE_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 16]> =
158 MultimapTableDefinition::new("relations_by_source");
159
160/// Index: Relations by target experience.
161///
162/// Enables efficient queries like "find all incoming relations to experience X".
163/// Key: ExperienceId (target) as 16-byte UUID
164/// Value (multimap): RelationId as 16-byte UUID
165pub const RELATIONS_BY_TARGET_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 16]> =
166 MultimapTableDefinition::new("relations_by_target");
167
168// ============================================================================
169// Insight Tables (E3-S02)
170// ============================================================================
171
172/// Insights table.
173///
174/// Primary storage for derived insights.
175/// Key: InsightId as 16-byte UUID
176/// Value: bincode-serialized DerivedInsight struct (with inline embedding)
177pub const INSIGHTS_TABLE: TableDefinition<&[u8; 16], &[u8]> = TableDefinition::new("insights");
178
179/// Index: Insights by collective.
180///
181/// Enables efficient queries like "find all insights in collective X".
182/// Key: CollectiveId as 16-byte UUID
183/// Value (multimap): InsightId as 16-byte UUID
184pub const INSIGHTS_BY_COLLECTIVE_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 16]> =
185 MultimapTableDefinition::new("insights_by_collective");
186
187// ============================================================================
188// Activity Tables (E3-S03)
189// ============================================================================
190
191/// Activities table — agent presence tracking.
192///
193/// First PulseDB table using variable-length keys. Activities are keyed by
194/// a composite `(collective_id, agent_id)` rather than a UUID, since each
195/// agent can have at most one active session per collective.
196///
197/// Key: `[collective_id: 16B][agent_id_len: 2B BE][agent_id: NB]`
198/// Value: bincode-serialized Activity struct
199pub const ACTIVITIES_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("activities");
200
201// ============================================================================
202// Watch Events Tables (E4-S02)
203// ============================================================================
204
205/// Metadata key for the current WAL sequence number.
206///
207/// Stored in `METADATA_TABLE` as 8-byte big-endian `u64`.
208/// Starts at 0 (no writes yet), incremented atomically within each
209/// experience write transaction.
210pub const WAL_SEQUENCE_KEY: &str = "wal_sequence";
211
212/// Watch events table — cross-process change detection log.
213///
214/// Each experience mutation (create, update, archive, delete) records an
215/// entry here with a monotonically increasing sequence number as the key.
216/// Reader processes poll this table to discover changes made by the writer.
217///
218/// Key: u64 sequence number as 8-byte big-endian (lexicographic = numeric order)
219/// Value: bincode-serialized `WatchEventRecord`
220///
221/// The table grows unboundedly; a future compaction feature will allow
222/// trimming old entries.
223pub const WATCH_EVENTS_TABLE: TableDefinition<&[u8; 8], &[u8]> =
224 TableDefinition::new("watch_events");
225
226/// A persisted watch event for cross-process change detection.
227///
228/// This is the on-disk representation — compact and self-contained.
229/// Converted to the public `WatchEvent` type when returned to callers.
230///
231/// Uses raw byte arrays for IDs (not UUID wrappers) to keep serialization
232/// simple and avoid coupling the storage format to the public type system.
233#[derive(Clone, Debug, Serialize, Deserialize)]
234pub struct WatchEventRecord {
235 /// The experience that changed (16-byte UUID).
236 pub experience_id: [u8; 16],
237
238 /// The collective the experience belongs to (16-byte UUID).
239 pub collective_id: [u8; 16],
240
241 /// What kind of change occurred.
242 pub event_type: WatchEventTypeTag,
243
244 /// When the change occurred (milliseconds since Unix epoch).
245 pub timestamp_ms: i64,
246}
247
248/// Compact tag for watch event types stored on disk.
249///
250/// Mirrors `WatchEventType` from `watch/types.rs` but uses `repr(u8)` for
251/// minimal storage footprint. Derives `Serialize`/`Deserialize` since it's
252/// part of the bincode-serialized `WatchEventRecord`.
253#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
254#[repr(u8)]
255pub enum WatchEventTypeTag {
256 /// A new experience was recorded.
257 Created = 0,
258 /// An existing experience was modified or reinforced.
259 Updated = 1,
260 /// An experience was soft-deleted (archived).
261 Archived = 2,
262 /// An experience was permanently deleted.
263 Deleted = 3,
264}
265
266impl WatchEventTypeTag {
267 /// Converts a raw byte to a WatchEventTypeTag.
268 ///
269 /// Returns `None` if the byte doesn't correspond to a known variant.
270 pub fn from_u8(value: u8) -> Option<Self> {
271 match value {
272 0 => Some(Self::Created),
273 1 => Some(Self::Updated),
274 2 => Some(Self::Archived),
275 3 => Some(Self::Deleted),
276 _ => None,
277 }
278 }
279}
280
281// ============================================================================
282// Experience Type Tag
283// ============================================================================
284
285/// Compact discriminant for experience types, used in secondary index keys.
286///
287/// Each variant maps to a single byte (`repr(u8)`), making index keys small
288/// and comparison fast. The full `ExperienceType` enum (with associated data)
289/// lives in `experience/types.rs` and bridges to this tag via `type_tag()`.
290///
291/// # Variants (9, per ADR-004 / Data Model spec)
292///
293/// - `Difficulty` — Problem encountered by the agent
294/// - `Solution` — Fix for a problem (can link to Difficulty)
295/// - `ErrorPattern` — Reusable error signature + fix + prevention
296/// - `SuccessPattern` — Proven approach with quality rating
297/// - `UserPreference` — User preference with strength
298/// - `ArchitecturalDecision` — Design decision with rationale
299/// - `TechInsight` — Technical knowledge about a technology
300/// - `Fact` — Verified factual statement with source
301/// - `Generic` — Catch-all for uncategorized experiences
302#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
303#[repr(u8)]
304pub enum ExperienceTypeTag {
305 /// Problem encountered by the agent.
306 Difficulty = 0,
307 /// Fix for a problem (can reference a Difficulty).
308 Solution = 1,
309 /// Reusable error signature with fix and prevention.
310 ErrorPattern = 2,
311 /// Proven approach with quality rating.
312 SuccessPattern = 3,
313 /// User preference with strength.
314 UserPreference = 4,
315 /// Design decision with rationale.
316 ArchitecturalDecision = 5,
317 /// Technical knowledge about a technology.
318 TechInsight = 6,
319 /// Verified factual statement with source.
320 Fact = 7,
321 /// Catch-all for uncategorized experiences.
322 Generic = 8,
323}
324
325impl ExperienceTypeTag {
326 /// Converts a raw byte to an ExperienceTypeTag.
327 ///
328 /// Returns `None` if the byte doesn't correspond to a known variant.
329 pub fn from_u8(value: u8) -> Option<Self> {
330 match value {
331 0 => Some(Self::Difficulty),
332 1 => Some(Self::Solution),
333 2 => Some(Self::ErrorPattern),
334 3 => Some(Self::SuccessPattern),
335 4 => Some(Self::UserPreference),
336 5 => Some(Self::ArchitecturalDecision),
337 6 => Some(Self::TechInsight),
338 7 => Some(Self::Fact),
339 8 => Some(Self::Generic),
340 _ => None,
341 }
342 }
343
344 /// Returns all variants in discriminant order.
345 pub fn all() -> &'static [Self] {
346 &[
347 Self::Difficulty,
348 Self::Solution,
349 Self::ErrorPattern,
350 Self::SuccessPattern,
351 Self::UserPreference,
352 Self::ArchitecturalDecision,
353 Self::TechInsight,
354 Self::Fact,
355 Self::Generic,
356 ]
357 }
358}
359
360// ============================================================================
361// Database Metadata
362// ============================================================================
363
364/// Database metadata stored in the metadata table.
365///
366/// This is serialized with bincode and stored under the key "db_metadata".
367#[derive(Clone, Debug, Serialize, Deserialize)]
368pub struct DatabaseMetadata {
369 /// Schema version for compatibility checking.
370 pub schema_version: u32,
371
372 /// Embedding dimension configured for this database.
373 ///
374 /// Once set, this cannot be changed without recreating the database.
375 pub embedding_dimension: EmbeddingDimension,
376
377 /// Timestamp when the database was created.
378 pub created_at: Timestamp,
379
380 /// Last time the database was opened (updated on each open).
381 pub last_opened_at: Timestamp,
382}
383
384impl DatabaseMetadata {
385 /// Creates new metadata for a fresh database.
386 pub fn new(embedding_dimension: EmbeddingDimension) -> Self {
387 let now = Timestamp::now();
388 Self {
389 schema_version: SCHEMA_VERSION,
390 embedding_dimension,
391 created_at: now,
392 last_opened_at: now,
393 }
394 }
395
396 /// Updates the last_opened_at timestamp.
397 pub fn touch(&mut self) {
398 self.last_opened_at = Timestamp::now();
399 }
400
401 /// Checks if this metadata is compatible with the current schema.
402 pub fn is_compatible(&self) -> bool {
403 self.schema_version == SCHEMA_VERSION
404 }
405}
406
407// ============================================================================
408// Key Encoding Helpers
409// ============================================================================
410
411/// Encodes a (CollectiveId, Timestamp, ExperienceId) tuple for the index.
412///
413/// Format: [collective_id: 16 bytes][timestamp_be: 8 bytes] = 24 bytes
414/// (ExperienceId is the multimap value, not part of the key)
415///
416/// Big-endian timestamp ensures lexicographic ordering matches time ordering.
417#[inline]
418pub fn encode_collective_timestamp_key(collective_id: &[u8; 16], timestamp: Timestamp) -> [u8; 24] {
419 let mut key = [0u8; 24];
420 key[..16].copy_from_slice(collective_id);
421 key[16..24].copy_from_slice(×tamp.to_be_bytes());
422 key
423}
424
425/// Decodes the timestamp from a collective index key.
426#[inline]
427pub fn decode_timestamp_from_key(key: &[u8; 24]) -> Timestamp {
428 let mut bytes = [0u8; 8];
429 bytes.copy_from_slice(&key[16..24]);
430 Timestamp::from_millis(i64::from_be_bytes(bytes))
431}
432
433/// Creates a range start key for querying experiences in a collective.
434///
435/// Uses timestamp 0 (Unix epoch) as the start. We don't support timestamps
436/// before 1970 since that predates computers being useful for AI agents.
437#[inline]
438pub fn collective_range_start(collective_id: &[u8; 16]) -> [u8; 24] {
439 encode_collective_timestamp_key(collective_id, Timestamp::from_millis(0))
440}
441
442/// Creates a range end key for querying experiences in a collective.
443///
444/// Uses maximum positive timestamp to include all experiences.
445#[inline]
446pub fn collective_range_end(collective_id: &[u8; 16]) -> [u8; 24] {
447 encode_collective_timestamp_key(collective_id, Timestamp::from_millis(i64::MAX))
448}
449
450// ============================================================================
451// Type Index Key Encoding
452// ============================================================================
453
454/// Encodes a (CollectiveId, ExperienceTypeTag) key for the type index.
455///
456/// Format: [collective_id: 16 bytes][type_tag: 1 byte] = 17 bytes
457///
458/// This key design allows efficient range queries: to find all experiences
459/// of a given type in a collective, we do a point lookup on this 17-byte key
460/// and iterate the multimap values (ExperienceIds).
461#[inline]
462pub fn encode_type_index_key(collective_id: &[u8; 16], type_tag: ExperienceTypeTag) -> [u8; 17] {
463 let mut key = [0u8; 17];
464 key[..16].copy_from_slice(collective_id);
465 key[16] = type_tag as u8;
466 key
467}
468
469/// Decodes the ExperienceTypeTag from a type index key.
470///
471/// Returns `None` if the tag byte doesn't correspond to a known variant.
472#[inline]
473pub fn decode_type_tag_from_key(key: &[u8; 17]) -> Option<ExperienceTypeTag> {
474 ExperienceTypeTag::from_u8(key[16])
475}
476
477/// Decodes the CollectiveId bytes from a type index key.
478#[inline]
479pub fn decode_collective_from_type_key(key: &[u8; 17]) -> [u8; 16] {
480 let mut id = [0u8; 16];
481 id.copy_from_slice(&key[..16]);
482 id
483}
484
485// ============================================================================
486// Activity Key Encoding (E3-S03)
487// ============================================================================
488
489/// Encodes a `(collective_id, agent_id)` composite key for the activities table.
490///
491/// Format: `[collective_id: 16 bytes][agent_id_len: 2 bytes BE u16][agent_id: N bytes]`
492///
493/// The collective_id prefix allows efficient filtering by collective (prefix scan).
494/// The 2-byte length field enables safe decoding of the variable-length agent_id.
495#[inline]
496pub fn encode_activity_key(collective_id: &[u8; 16], agent_id: &str) -> Vec<u8> {
497 let agent_bytes = agent_id.as_bytes();
498 let len = agent_bytes.len() as u16;
499 let mut key = Vec::with_capacity(16 + 2 + agent_bytes.len());
500 key.extend_from_slice(collective_id);
501 key.extend_from_slice(&len.to_be_bytes());
502 key.extend_from_slice(agent_bytes);
503 key
504}
505
506/// Extracts the 16-byte CollectiveId from an activity key.
507///
508/// # Panics
509///
510/// Panics if the key is shorter than 16 bytes (should never happen with
511/// properly encoded keys from `encode_activity_key`).
512#[inline]
513pub fn decode_collective_from_activity_key(key: &[u8]) -> [u8; 16] {
514 let mut id = [0u8; 16];
515 id.copy_from_slice(&key[..16]);
516 id
517}
518
519/// Extracts the agent_id string from an activity key.
520///
521/// Reads the 2-byte length at offset 16, then slices the UTF-8 agent_id.
522///
523/// # Panics
524///
525/// Panics if the key is malformed (insufficient length or invalid UTF-8).
526/// This should never happen with keys created by `encode_activity_key`.
527#[inline]
528pub fn decode_agent_id_from_activity_key(key: &[u8]) -> &str {
529 let len = u16::from_be_bytes([key[16], key[17]]) as usize;
530 std::str::from_utf8(&key[18..18 + len]).expect("activity key contains invalid UTF-8")
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn test_schema_version() {
539 assert_eq!(SCHEMA_VERSION, 1);
540 }
541
542 #[test]
543 fn test_database_metadata_new() {
544 let meta = DatabaseMetadata::new(EmbeddingDimension::D384);
545 assert_eq!(meta.schema_version, SCHEMA_VERSION);
546 assert_eq!(meta.embedding_dimension, EmbeddingDimension::D384);
547 assert!(meta.is_compatible());
548 }
549
550 #[test]
551 fn test_database_metadata_touch() {
552 let mut meta = DatabaseMetadata::new(EmbeddingDimension::D384);
553 let original = meta.last_opened_at;
554 std::thread::sleep(std::time::Duration::from_millis(1));
555 meta.touch();
556 assert!(meta.last_opened_at > original);
557 }
558
559 #[test]
560 fn test_database_metadata_serialization() {
561 let meta = DatabaseMetadata::new(EmbeddingDimension::D768);
562 let bytes = bincode::serialize(&meta).unwrap();
563 let restored: DatabaseMetadata = bincode::deserialize(&bytes).unwrap();
564 assert_eq!(meta.schema_version, restored.schema_version);
565 assert_eq!(meta.embedding_dimension, restored.embedding_dimension);
566 }
567
568 #[test]
569 fn test_encode_collective_timestamp_key() {
570 let collective_id = [1u8; 16];
571 let timestamp = Timestamp::from_millis(1234567890);
572
573 let key = encode_collective_timestamp_key(&collective_id, timestamp);
574
575 assert_eq!(&key[..16], &collective_id);
576 assert_eq!(decode_timestamp_from_key(&key), timestamp);
577 }
578
579 #[test]
580 fn test_key_ordering() {
581 let collective_id = [1u8; 16];
582 let t1 = Timestamp::from_millis(1000);
583 let t2 = Timestamp::from_millis(2000);
584
585 let key1 = encode_collective_timestamp_key(&collective_id, t1);
586 let key2 = encode_collective_timestamp_key(&collective_id, t2);
587
588 // Lexicographic ordering should match timestamp ordering
589 assert!(key1 < key2);
590 }
591
592 #[test]
593 fn test_collective_range() {
594 let collective_id = [42u8; 16];
595 let start = collective_range_start(&collective_id);
596 let end = collective_range_end(&collective_id);
597
598 // Any timestamp should fall within this range
599 let mid = encode_collective_timestamp_key(&collective_id, Timestamp::now());
600 assert!(start <= mid);
601 assert!(mid <= end);
602 }
603
604 // ====================================================================
605 // ExperienceTypeTag tests
606 // ====================================================================
607
608 #[test]
609 fn test_experience_type_tag_from_u8_roundtrip() {
610 for tag in ExperienceTypeTag::all() {
611 let byte = *tag as u8;
612 let restored = ExperienceTypeTag::from_u8(byte).unwrap();
613 assert_eq!(*tag, restored);
614 }
615 }
616
617 #[test]
618 fn test_experience_type_tag_from_u8_invalid() {
619 assert!(ExperienceTypeTag::from_u8(255).is_none());
620 assert!(ExperienceTypeTag::from_u8(9).is_none());
621 }
622
623 #[test]
624 fn test_experience_type_tag_all_variants() {
625 let all = ExperienceTypeTag::all();
626 assert_eq!(all.len(), 9);
627 assert_eq!(all[0], ExperienceTypeTag::Difficulty);
628 assert_eq!(all[5], ExperienceTypeTag::ArchitecturalDecision);
629 assert_eq!(all[8], ExperienceTypeTag::Generic);
630 }
631
632 #[test]
633 fn test_experience_type_tag_bincode_roundtrip() {
634 for tag in ExperienceTypeTag::all() {
635 let bytes = bincode::serialize(tag).unwrap();
636 let restored: ExperienceTypeTag = bincode::deserialize(&bytes).unwrap();
637 assert_eq!(*tag, restored);
638 }
639 }
640
641 // ====================================================================
642 // Type index key encoding tests
643 // ====================================================================
644
645 #[test]
646 fn test_encode_type_index_key_roundtrip() {
647 let collective_id = [7u8; 16];
648 let tag = ExperienceTypeTag::SuccessPattern;
649
650 let key = encode_type_index_key(&collective_id, tag);
651
652 assert_eq!(decode_collective_from_type_key(&key), collective_id);
653 assert_eq!(decode_type_tag_from_key(&key), Some(tag));
654 }
655
656 #[test]
657 fn test_type_index_key_different_types_produce_different_keys() {
658 let collective_id = [1u8; 16];
659
660 let key_obs = encode_type_index_key(&collective_id, ExperienceTypeTag::Difficulty);
661 let key_les = encode_type_index_key(&collective_id, ExperienceTypeTag::SuccessPattern);
662
663 assert_ne!(key_obs, key_les);
664 // Same collective prefix
665 assert_eq!(&key_obs[..16], &key_les[..16]);
666 // Different type byte
667 assert_ne!(key_obs[16], key_les[16]);
668 }
669
670 #[test]
671 fn test_type_index_key_different_collectives_produce_different_keys() {
672 let id_a = [1u8; 16];
673 let id_b = [2u8; 16];
674 let tag = ExperienceTypeTag::Solution;
675
676 let key_a = encode_type_index_key(&id_a, tag);
677 let key_b = encode_type_index_key(&id_b, tag);
678
679 assert_ne!(key_a, key_b);
680 // Same type byte
681 assert_eq!(key_a[16], key_b[16]);
682 }
683
684 // ====================================================================
685 // Activity key encoding tests (E3-S03)
686 // ====================================================================
687
688 #[test]
689 fn test_activity_key_encode_decode_roundtrip() {
690 let collective_id = [42u8; 16];
691 let agent_id = "claude-opus";
692
693 let key = encode_activity_key(&collective_id, agent_id);
694
695 assert_eq!(decode_collective_from_activity_key(&key), collective_id);
696 assert_eq!(decode_agent_id_from_activity_key(&key), agent_id);
697 }
698
699 #[test]
700 fn test_activity_key_different_agents_produce_different_keys() {
701 let collective_id = [1u8; 16];
702
703 let key_a = encode_activity_key(&collective_id, "agent-alpha");
704 let key_b = encode_activity_key(&collective_id, "agent-beta");
705
706 assert_ne!(key_a, key_b);
707 // Same collective prefix
708 assert_eq!(&key_a[..16], &key_b[..16]);
709 }
710
711 #[test]
712 fn test_activity_key_different_collectives_produce_different_keys() {
713 let id_a = [1u8; 16];
714 let id_b = [2u8; 16];
715
716 let key_a = encode_activity_key(&id_a, "same-agent");
717 let key_b = encode_activity_key(&id_b, "same-agent");
718
719 assert_ne!(key_a, key_b);
720 // Same agent_id suffix
721 assert_eq!(
722 decode_agent_id_from_activity_key(&key_a),
723 decode_agent_id_from_activity_key(&key_b)
724 );
725 }
726
727 #[test]
728 fn test_activity_key_format() {
729 let collective_id = [0xAB; 16];
730 let agent_id = "hi";
731
732 let key = encode_activity_key(&collective_id, agent_id);
733
734 // 16 (collective) + 2 (len) + 2 (agent "hi") = 20 bytes
735 assert_eq!(key.len(), 20);
736 // Collective prefix
737 assert_eq!(&key[..16], &[0xAB; 16]);
738 // Length field (big-endian u16 = 2)
739 assert_eq!(&key[16..18], &[0, 2]);
740 // Agent ID bytes
741 assert_eq!(&key[18..], b"hi");
742 }
743}