Skip to main content

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/// Version 2 adds `entity_type` to `WatchEventRecord` for sync protocol support.
45pub const SCHEMA_VERSION: u32 = 2;
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// ============================================================================
206// Sync Metadata (feature: sync)
207// ============================================================================
208
209/// Metadata key for the instance ID (16-byte UUID v7).
210///
211/// Stored in `METADATA_TABLE` as raw 16 bytes. Generated on first open
212/// and persisted for the lifetime of the database. Used by the sync
213/// protocol to identify this PulseDB instance.
214#[cfg(feature = "sync")]
215pub const INSTANCE_ID_KEY: &str = "instance_id";
216
217/// Sync cursors table — per-peer sync position tracking.
218///
219/// Each entry records the last WAL sequence number successfully synced
220/// with a specific peer instance. Key is the peer's InstanceId (16 bytes),
221/// value is bincode-serialized `SyncCursor`.
222#[cfg(feature = "sync")]
223pub const SYNC_CURSORS_TABLE: TableDefinition<&[u8; 16], &[u8]> =
224    TableDefinition::new("sync_cursors");
225
226// ============================================================================
227// Watch Events Tables (E4-S02)
228// ============================================================================
229
230/// Metadata key for the current WAL sequence number.
231///
232/// Stored in `METADATA_TABLE` as 8-byte big-endian `u64`.
233/// Starts at 0 (no writes yet), incremented atomically within each
234/// experience write transaction.
235pub const WAL_SEQUENCE_KEY: &str = "wal_sequence";
236
237/// Watch events table — cross-process change detection log.
238///
239/// Each experience mutation (create, update, archive, delete) records an
240/// entry here with a monotonically increasing sequence number as the key.
241/// Reader processes poll this table to discover changes made by the writer.
242///
243/// Key: u64 sequence number as 8-byte big-endian (lexicographic = numeric order)
244/// Value: bincode-serialized `WatchEventRecord`
245///
246/// The table grows unboundedly; a future compaction feature will allow
247/// trimming old entries.
248pub const WATCH_EVENTS_TABLE: TableDefinition<&[u8; 8], &[u8]> =
249    TableDefinition::new("watch_events");
250
251/// A persisted watch event for cross-process change detection (schema v2).
252///
253/// This is the on-disk representation — compact and self-contained.
254/// Converted to the public `WatchEvent` type when returned to callers.
255///
256/// Uses raw byte arrays for IDs (not UUID wrappers) to keep serialization
257/// simple and avoid coupling the storage format to the public type system.
258///
259/// # Schema v2 Changes
260///
261/// In v1, this struct only tracked experiences (`experience_id`). In v2,
262/// the field is renamed to `entity_id` and an `entity_type` discriminant
263/// is added to track all entity types (relations, insights, collectives).
264#[derive(Clone, Debug, Serialize, Deserialize)]
265pub struct WatchEventRecord {
266    /// The entity that changed (16-byte UUID).
267    ///
268    /// For experiences this is an ExperienceId, for relations a RelationId, etc.
269    pub entity_id: [u8; 16],
270
271    /// The collective this entity belongs to (16-byte UUID).
272    pub collective_id: [u8; 16],
273
274    /// What kind of change occurred.
275    pub event_type: WatchEventTypeTag,
276
277    /// When the change occurred (milliseconds since Unix epoch).
278    pub timestamp_ms: i64,
279
280    /// What kind of entity changed (schema v2).
281    pub entity_type: EntityTypeTag,
282}
283
284/// Schema v1 watch event record (for migration deserialization only).
285///
286/// In v1, the WAL only tracked experience mutations. This struct matches
287/// the v1 bincode layout for reading old records during migration.
288#[derive(Deserialize)]
289pub(crate) struct WatchEventRecordV1 {
290    pub experience_id: [u8; 16],
291    pub collective_id: [u8; 16],
292    pub event_type: WatchEventTypeTag,
293    pub timestamp_ms: i64,
294}
295
296/// Compact tag for watch event types stored on disk.
297///
298/// Mirrors `WatchEventType` from `watch/types.rs` but uses `repr(u8)` for
299/// minimal storage footprint. Derives `Serialize`/`Deserialize` since it's
300/// part of the bincode-serialized `WatchEventRecord`.
301#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
302#[repr(u8)]
303pub enum WatchEventTypeTag {
304    /// A new experience was recorded.
305    Created = 0,
306    /// An existing experience was modified or reinforced.
307    Updated = 1,
308    /// An experience was soft-deleted (archived).
309    Archived = 2,
310    /// An experience was permanently deleted.
311    Deleted = 3,
312}
313
314impl WatchEventTypeTag {
315    /// Converts a raw byte to a WatchEventTypeTag.
316    ///
317    /// Returns `None` if the byte doesn't correspond to a known variant.
318    pub fn from_u8(value: u8) -> Option<Self> {
319        match value {
320            0 => Some(Self::Created),
321            1 => Some(Self::Updated),
322            2 => Some(Self::Archived),
323            3 => Some(Self::Deleted),
324            _ => None,
325        }
326    }
327}
328
329// ============================================================================
330// Entity Type Tag (schema v2)
331// ============================================================================
332
333/// Compact discriminant for entity types in WAL records.
334///
335/// Identifies what kind of entity a WAL event refers to. Added in schema v2
336/// to extend WAL tracking beyond just experiences.
337#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
338#[repr(u8)]
339pub enum EntityTypeTag {
340    /// An experience entity.
341    #[default]
342    Experience = 0,
343    /// A relation between experiences.
344    Relation = 1,
345    /// A derived insight.
346    Insight = 2,
347    /// A collective.
348    Collective = 3,
349}
350
351impl EntityTypeTag {
352    /// Converts a raw byte to an EntityTypeTag.
353    ///
354    /// Returns `None` if the byte doesn't correspond to a known variant.
355    pub fn from_u8(value: u8) -> Option<Self> {
356        match value {
357            0 => Some(Self::Experience),
358            1 => Some(Self::Relation),
359            2 => Some(Self::Insight),
360            3 => Some(Self::Collective),
361            _ => None,
362        }
363    }
364}
365
366// ============================================================================
367// Experience Type Tag
368// ============================================================================
369
370/// Compact discriminant for experience types, used in secondary index keys.
371///
372/// Each variant maps to a single byte (`repr(u8)`), making index keys small
373/// and comparison fast. The full `ExperienceType` enum (with associated data)
374/// lives in `experience/types.rs` and bridges to this tag via `type_tag()`.
375///
376/// # Variants (9, per ADR-004 / Data Model spec)
377///
378/// - `Difficulty` — Problem encountered by the agent
379/// - `Solution` — Fix for a problem (can link to Difficulty)
380/// - `ErrorPattern` — Reusable error signature + fix + prevention
381/// - `SuccessPattern` — Proven approach with quality rating
382/// - `UserPreference` — User preference with strength
383/// - `ArchitecturalDecision` — Design decision with rationale
384/// - `TechInsight` — Technical knowledge about a technology
385/// - `Fact` — Verified factual statement with source
386/// - `Generic` — Catch-all for uncategorized experiences
387#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
388#[repr(u8)]
389pub enum ExperienceTypeTag {
390    /// Problem encountered by the agent.
391    Difficulty = 0,
392    /// Fix for a problem (can reference a Difficulty).
393    Solution = 1,
394    /// Reusable error signature with fix and prevention.
395    ErrorPattern = 2,
396    /// Proven approach with quality rating.
397    SuccessPattern = 3,
398    /// User preference with strength.
399    UserPreference = 4,
400    /// Design decision with rationale.
401    ArchitecturalDecision = 5,
402    /// Technical knowledge about a technology.
403    TechInsight = 6,
404    /// Verified factual statement with source.
405    Fact = 7,
406    /// Catch-all for uncategorized experiences.
407    Generic = 8,
408}
409
410impl ExperienceTypeTag {
411    /// Converts a raw byte to an ExperienceTypeTag.
412    ///
413    /// Returns `None` if the byte doesn't correspond to a known variant.
414    pub fn from_u8(value: u8) -> Option<Self> {
415        match value {
416            0 => Some(Self::Difficulty),
417            1 => Some(Self::Solution),
418            2 => Some(Self::ErrorPattern),
419            3 => Some(Self::SuccessPattern),
420            4 => Some(Self::UserPreference),
421            5 => Some(Self::ArchitecturalDecision),
422            6 => Some(Self::TechInsight),
423            7 => Some(Self::Fact),
424            8 => Some(Self::Generic),
425            _ => None,
426        }
427    }
428
429    /// Returns all variants in discriminant order.
430    pub fn all() -> &'static [Self] {
431        &[
432            Self::Difficulty,
433            Self::Solution,
434            Self::ErrorPattern,
435            Self::SuccessPattern,
436            Self::UserPreference,
437            Self::ArchitecturalDecision,
438            Self::TechInsight,
439            Self::Fact,
440            Self::Generic,
441        ]
442    }
443}
444
445// ============================================================================
446// Database Metadata
447// ============================================================================
448
449/// Database metadata stored in the metadata table.
450///
451/// This is serialized with bincode and stored under the key "db_metadata".
452#[derive(Clone, Debug, Serialize, Deserialize)]
453pub struct DatabaseMetadata {
454    /// Schema version for compatibility checking.
455    pub schema_version: u32,
456
457    /// Embedding dimension configured for this database.
458    ///
459    /// Once set, this cannot be changed without recreating the database.
460    pub embedding_dimension: EmbeddingDimension,
461
462    /// Timestamp when the database was created.
463    pub created_at: Timestamp,
464
465    /// Last time the database was opened (updated on each open).
466    pub last_opened_at: Timestamp,
467}
468
469impl DatabaseMetadata {
470    /// Creates new metadata for a fresh database.
471    pub fn new(embedding_dimension: EmbeddingDimension) -> Self {
472        let now = Timestamp::now();
473        Self {
474            schema_version: SCHEMA_VERSION,
475            embedding_dimension,
476            created_at: now,
477            last_opened_at: now,
478        }
479    }
480
481    /// Updates the last_opened_at timestamp.
482    pub fn touch(&mut self) {
483        self.last_opened_at = Timestamp::now();
484    }
485
486    /// Checks if this metadata is compatible with the current schema.
487    pub fn is_compatible(&self) -> bool {
488        self.schema_version == SCHEMA_VERSION
489    }
490}
491
492// ============================================================================
493// Key Encoding Helpers
494// ============================================================================
495
496/// Encodes a (CollectiveId, Timestamp, ExperienceId) tuple for the index.
497///
498/// Format: [collective_id: 16 bytes][timestamp_be: 8 bytes] = 24 bytes
499/// (ExperienceId is the multimap value, not part of the key)
500///
501/// Big-endian timestamp ensures lexicographic ordering matches time ordering.
502#[inline]
503pub fn encode_collective_timestamp_key(collective_id: &[u8; 16], timestamp: Timestamp) -> [u8; 24] {
504    let mut key = [0u8; 24];
505    key[..16].copy_from_slice(collective_id);
506    key[16..24].copy_from_slice(&timestamp.to_be_bytes());
507    key
508}
509
510/// Decodes the timestamp from a collective index key.
511#[inline]
512pub fn decode_timestamp_from_key(key: &[u8; 24]) -> Timestamp {
513    let mut bytes = [0u8; 8];
514    bytes.copy_from_slice(&key[16..24]);
515    Timestamp::from_millis(i64::from_be_bytes(bytes))
516}
517
518/// Creates a range start key for querying experiences in a collective.
519///
520/// Uses timestamp 0 (Unix epoch) as the start. We don't support timestamps
521/// before 1970 since that predates computers being useful for AI agents.
522#[inline]
523pub fn collective_range_start(collective_id: &[u8; 16]) -> [u8; 24] {
524    encode_collective_timestamp_key(collective_id, Timestamp::from_millis(0))
525}
526
527/// Creates a range end key for querying experiences in a collective.
528///
529/// Uses maximum positive timestamp to include all experiences.
530#[inline]
531pub fn collective_range_end(collective_id: &[u8; 16]) -> [u8; 24] {
532    encode_collective_timestamp_key(collective_id, Timestamp::from_millis(i64::MAX))
533}
534
535// ============================================================================
536// Type Index Key Encoding
537// ============================================================================
538
539/// Encodes a (CollectiveId, ExperienceTypeTag) key for the type index.
540///
541/// Format: [collective_id: 16 bytes][type_tag: 1 byte] = 17 bytes
542///
543/// This key design allows efficient range queries: to find all experiences
544/// of a given type in a collective, we do a point lookup on this 17-byte key
545/// and iterate the multimap values (ExperienceIds).
546#[inline]
547pub fn encode_type_index_key(collective_id: &[u8; 16], type_tag: ExperienceTypeTag) -> [u8; 17] {
548    let mut key = [0u8; 17];
549    key[..16].copy_from_slice(collective_id);
550    key[16] = type_tag as u8;
551    key
552}
553
554/// Decodes the ExperienceTypeTag from a type index key.
555///
556/// Returns `None` if the tag byte doesn't correspond to a known variant.
557#[inline]
558pub fn decode_type_tag_from_key(key: &[u8; 17]) -> Option<ExperienceTypeTag> {
559    ExperienceTypeTag::from_u8(key[16])
560}
561
562/// Decodes the CollectiveId bytes from a type index key.
563#[inline]
564pub fn decode_collective_from_type_key(key: &[u8; 17]) -> [u8; 16] {
565    let mut id = [0u8; 16];
566    id.copy_from_slice(&key[..16]);
567    id
568}
569
570// ============================================================================
571// Activity Key Encoding (E3-S03)
572// ============================================================================
573
574/// Encodes a `(collective_id, agent_id)` composite key for the activities table.
575///
576/// Format: `[collective_id: 16 bytes][agent_id_len: 2 bytes BE u16][agent_id: N bytes]`
577///
578/// The collective_id prefix allows efficient filtering by collective (prefix scan).
579/// The 2-byte length field enables safe decoding of the variable-length agent_id.
580#[inline]
581pub fn encode_activity_key(collective_id: &[u8; 16], agent_id: &str) -> Vec<u8> {
582    let agent_bytes = agent_id.as_bytes();
583    let len = agent_bytes.len() as u16;
584    let mut key = Vec::with_capacity(16 + 2 + agent_bytes.len());
585    key.extend_from_slice(collective_id);
586    key.extend_from_slice(&len.to_be_bytes());
587    key.extend_from_slice(agent_bytes);
588    key
589}
590
591/// Extracts the 16-byte CollectiveId from an activity key.
592///
593/// # Panics
594///
595/// Panics if the key is shorter than 16 bytes (should never happen with
596/// properly encoded keys from `encode_activity_key`).
597#[inline]
598pub fn decode_collective_from_activity_key(key: &[u8]) -> [u8; 16] {
599    let mut id = [0u8; 16];
600    id.copy_from_slice(&key[..16]);
601    id
602}
603
604/// Extracts the agent_id string from an activity key.
605///
606/// Reads the 2-byte length at offset 16, then slices the UTF-8 agent_id.
607///
608/// # Panics
609///
610/// Panics if the key is malformed (insufficient length or invalid UTF-8).
611/// This should never happen with keys created by `encode_activity_key`.
612#[inline]
613pub fn decode_agent_id_from_activity_key(key: &[u8]) -> &str {
614    let len = u16::from_be_bytes([key[16], key[17]]) as usize;
615    std::str::from_utf8(&key[18..18 + len]).expect("activity key contains invalid UTF-8")
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_schema_version() {
624        assert_eq!(SCHEMA_VERSION, 2);
625    }
626
627    #[test]
628    fn test_database_metadata_new() {
629        let meta = DatabaseMetadata::new(EmbeddingDimension::D384);
630        assert_eq!(meta.schema_version, SCHEMA_VERSION);
631        assert_eq!(meta.embedding_dimension, EmbeddingDimension::D384);
632        assert!(meta.is_compatible());
633    }
634
635    #[test]
636    fn test_database_metadata_touch() {
637        let mut meta = DatabaseMetadata::new(EmbeddingDimension::D384);
638        let original = meta.last_opened_at;
639        std::thread::sleep(std::time::Duration::from_millis(1));
640        meta.touch();
641        assert!(meta.last_opened_at > original);
642    }
643
644    #[test]
645    fn test_database_metadata_serialization() {
646        let meta = DatabaseMetadata::new(EmbeddingDimension::D768);
647        let bytes = bincode::serialize(&meta).unwrap();
648        let restored: DatabaseMetadata = bincode::deserialize(&bytes).unwrap();
649        assert_eq!(meta.schema_version, restored.schema_version);
650        assert_eq!(meta.embedding_dimension, restored.embedding_dimension);
651    }
652
653    #[test]
654    fn test_encode_collective_timestamp_key() {
655        let collective_id = [1u8; 16];
656        let timestamp = Timestamp::from_millis(1234567890);
657
658        let key = encode_collective_timestamp_key(&collective_id, timestamp);
659
660        assert_eq!(&key[..16], &collective_id);
661        assert_eq!(decode_timestamp_from_key(&key), timestamp);
662    }
663
664    #[test]
665    fn test_key_ordering() {
666        let collective_id = [1u8; 16];
667        let t1 = Timestamp::from_millis(1000);
668        let t2 = Timestamp::from_millis(2000);
669
670        let key1 = encode_collective_timestamp_key(&collective_id, t1);
671        let key2 = encode_collective_timestamp_key(&collective_id, t2);
672
673        // Lexicographic ordering should match timestamp ordering
674        assert!(key1 < key2);
675    }
676
677    #[test]
678    fn test_collective_range() {
679        let collective_id = [42u8; 16];
680        let start = collective_range_start(&collective_id);
681        let end = collective_range_end(&collective_id);
682
683        // Any timestamp should fall within this range
684        let mid = encode_collective_timestamp_key(&collective_id, Timestamp::now());
685        assert!(start <= mid);
686        assert!(mid <= end);
687    }
688
689    // ====================================================================
690    // ExperienceTypeTag tests
691    // ====================================================================
692
693    #[test]
694    fn test_experience_type_tag_from_u8_roundtrip() {
695        for tag in ExperienceTypeTag::all() {
696            let byte = *tag as u8;
697            let restored = ExperienceTypeTag::from_u8(byte).unwrap();
698            assert_eq!(*tag, restored);
699        }
700    }
701
702    #[test]
703    fn test_experience_type_tag_from_u8_invalid() {
704        assert!(ExperienceTypeTag::from_u8(255).is_none());
705        assert!(ExperienceTypeTag::from_u8(9).is_none());
706    }
707
708    #[test]
709    fn test_experience_type_tag_all_variants() {
710        let all = ExperienceTypeTag::all();
711        assert_eq!(all.len(), 9);
712        assert_eq!(all[0], ExperienceTypeTag::Difficulty);
713        assert_eq!(all[5], ExperienceTypeTag::ArchitecturalDecision);
714        assert_eq!(all[8], ExperienceTypeTag::Generic);
715    }
716
717    #[test]
718    fn test_experience_type_tag_bincode_roundtrip() {
719        for tag in ExperienceTypeTag::all() {
720            let bytes = bincode::serialize(tag).unwrap();
721            let restored: ExperienceTypeTag = bincode::deserialize(&bytes).unwrap();
722            assert_eq!(*tag, restored);
723        }
724    }
725
726    // ====================================================================
727    // Type index key encoding tests
728    // ====================================================================
729
730    #[test]
731    fn test_encode_type_index_key_roundtrip() {
732        let collective_id = [7u8; 16];
733        let tag = ExperienceTypeTag::SuccessPattern;
734
735        let key = encode_type_index_key(&collective_id, tag);
736
737        assert_eq!(decode_collective_from_type_key(&key), collective_id);
738        assert_eq!(decode_type_tag_from_key(&key), Some(tag));
739    }
740
741    #[test]
742    fn test_type_index_key_different_types_produce_different_keys() {
743        let collective_id = [1u8; 16];
744
745        let key_obs = encode_type_index_key(&collective_id, ExperienceTypeTag::Difficulty);
746        let key_les = encode_type_index_key(&collective_id, ExperienceTypeTag::SuccessPattern);
747
748        assert_ne!(key_obs, key_les);
749        // Same collective prefix
750        assert_eq!(&key_obs[..16], &key_les[..16]);
751        // Different type byte
752        assert_ne!(key_obs[16], key_les[16]);
753    }
754
755    #[test]
756    fn test_type_index_key_different_collectives_produce_different_keys() {
757        let id_a = [1u8; 16];
758        let id_b = [2u8; 16];
759        let tag = ExperienceTypeTag::Solution;
760
761        let key_a = encode_type_index_key(&id_a, tag);
762        let key_b = encode_type_index_key(&id_b, tag);
763
764        assert_ne!(key_a, key_b);
765        // Same type byte
766        assert_eq!(key_a[16], key_b[16]);
767    }
768
769    // ====================================================================
770    // Activity key encoding tests (E3-S03)
771    // ====================================================================
772
773    #[test]
774    fn test_activity_key_encode_decode_roundtrip() {
775        let collective_id = [42u8; 16];
776        let agent_id = "claude-opus";
777
778        let key = encode_activity_key(&collective_id, agent_id);
779
780        assert_eq!(decode_collective_from_activity_key(&key), collective_id);
781        assert_eq!(decode_agent_id_from_activity_key(&key), agent_id);
782    }
783
784    #[test]
785    fn test_activity_key_different_agents_produce_different_keys() {
786        let collective_id = [1u8; 16];
787
788        let key_a = encode_activity_key(&collective_id, "agent-alpha");
789        let key_b = encode_activity_key(&collective_id, "agent-beta");
790
791        assert_ne!(key_a, key_b);
792        // Same collective prefix
793        assert_eq!(&key_a[..16], &key_b[..16]);
794    }
795
796    #[test]
797    fn test_activity_key_different_collectives_produce_different_keys() {
798        let id_a = [1u8; 16];
799        let id_b = [2u8; 16];
800
801        let key_a = encode_activity_key(&id_a, "same-agent");
802        let key_b = encode_activity_key(&id_b, "same-agent");
803
804        assert_ne!(key_a, key_b);
805        // Same agent_id suffix
806        assert_eq!(
807            decode_agent_id_from_activity_key(&key_a),
808            decode_agent_id_from_activity_key(&key_b)
809        );
810    }
811
812    #[test]
813    fn test_activity_key_format() {
814        let collective_id = [0xAB; 16];
815        let agent_id = "hi";
816
817        let key = encode_activity_key(&collective_id, agent_id);
818
819        // 16 (collective) + 2 (len) + 2 (agent "hi") = 20 bytes
820        assert_eq!(key.len(), 20);
821        // Collective prefix
822        assert_eq!(&key[..16], &[0xAB; 16]);
823        // Length field (big-endian u16 = 2)
824        assert_eq!(&key[16..18], &[0, 2]);
825        // Agent ID bytes
826        assert_eq!(&key[18..], b"hi");
827    }
828}