Skip to main content

hora_graph_core/
lib.rs

1//! hora-graph-core — Bio-inspired embedded knowledge graph engine.
2//!
3//! A pure-Rust knowledge graph with bi-temporal facts, vector/BM25 hybrid search,
4//! ACT-R memory activation, reconsolidation, dark nodes, FSRS scheduling,
5//! and a dream cycle for memory consolidation.
6
7pub mod core;
8pub mod error;
9pub mod memory;
10pub mod search;
11pub mod storage;
12
13pub use crate::core::edge::Edge;
14pub use crate::core::entity::Entity;
15pub use crate::core::episode::Episode;
16pub use crate::core::types::{
17    DedupConfig, EdgeId, EntityId, EntityUpdate, EpisodeSource, FactUpdate, HoraConfig, Properties,
18    PropertyValue, StorageStats, TraverseOpts, TraverseResult,
19};
20pub use crate::error::{HoraError, Result};
21pub use crate::memory::consolidation::{
22    ClsStats, ConsolidationParams, DreamCycleConfig, DreamCycleStats, LinkingStats, ReplayStats,
23};
24pub use crate::memory::dark_nodes::DarkNodeParams;
25pub use crate::memory::fsrs::FsrsParams;
26pub use crate::memory::reconsolidation::{MemoryPhase, ReconsolidationParams};
27pub use crate::memory::spreading::SpreadingParams;
28pub use crate::search::{SearchHit, SearchOpts};
29pub use crate::storage::format::{verify_file, VerifyReport};
30
31use std::collections::{HashMap, HashSet, VecDeque};
32use std::path::{Path, PathBuf};
33
34use crate::core::types::now_millis;
35use crate::memory::activation::ActivationState;
36use crate::memory::fsrs::FsrsState;
37use crate::memory::reconsolidation::ReconsolidationState;
38use crate::search::bm25::{self, Bm25Index};
39use crate::storage::format::{self, FileHeader};
40use crate::storage::memory::MemoryStorage;
41use crate::storage::traits::StorageOps;
42
43/// The main entry point for hora-graph-core.
44///
45/// ```
46/// use hora_graph_core::{HoraCore, HoraConfig};
47///
48/// let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
49/// let id = hora.add_entity("project", "hora", None, None).unwrap();
50/// let entity = hora.get_entity(id).unwrap().unwrap();
51/// assert_eq!(entity.name, "hora");
52/// ```
53pub struct HoraCore {
54    config: HoraConfig,
55    storage: Box<dyn StorageOps>,
56    next_entity_id: u64,
57    next_edge_id: u64,
58    next_episode_id: u64,
59    file_path: Option<PathBuf>,
60    bm25_index: Bm25Index,
61    bm25_built: bool,
62    pending_accesses: Vec<EntityId>,
63    activation_states: HashMap<EntityId, ActivationState>,
64    reconsolidation_states: HashMap<EntityId, ReconsolidationState>,
65    reconsolidation_params: ReconsolidationParams,
66    dark_node_params: DarkNodeParams,
67    fsrs_states: HashMap<EntityId, FsrsState>,
68    fsrs_params: FsrsParams,
69    consolidation_params: ConsolidationParams,
70}
71
72impl HoraCore {
73    /// Create a new in-memory HoraCore instance (no persistence).
74    pub fn new(config: HoraConfig) -> Result<Self> {
75        Ok(Self {
76            config,
77            storage: Box::new(MemoryStorage::new()),
78            next_entity_id: 1,
79            next_edge_id: 1,
80            next_episode_id: 1,
81            file_path: None,
82            bm25_index: Bm25Index::new(),
83            bm25_built: true,
84            pending_accesses: Vec::new(),
85            activation_states: HashMap::new(),
86            reconsolidation_states: HashMap::new(),
87            reconsolidation_params: ReconsolidationParams::default(),
88            dark_node_params: DarkNodeParams::default(),
89            fsrs_states: HashMap::new(),
90            fsrs_params: FsrsParams::default(),
91            consolidation_params: ConsolidationParams::default(),
92        })
93    }
94
95    /// Open a file-backed HoraCore instance.
96    ///
97    /// If the file exists, loads data from it. If it does not exist, creates
98    /// a new empty instance that will write to the given path on `flush()`.
99    pub fn open(path: impl AsRef<Path>, config: HoraConfig) -> Result<Self> {
100        let path = path.as_ref().to_path_buf();
101
102        if path.exists() {
103            let file = std::fs::File::open(&path)?;
104            let mut reader = std::io::BufReader::new(file);
105            let graph = format::deserialize(&mut reader)?;
106
107            // Rebuild MemoryStorage from deserialized data (BM25 is lazy)
108            let mut storage = MemoryStorage::new();
109            for entity in graph.entities {
110                storage.put_entity(entity)?;
111            }
112            for edge in graph.edges {
113                storage.put_edge(edge)?;
114            }
115            for episode in graph.episodes {
116                storage.put_episode(episode)?;
117            }
118
119            Ok(Self {
120                config: HoraConfig {
121                    embedding_dims: graph.header.embedding_dims,
122                    ..Default::default()
123                },
124                storage: Box::new(storage),
125                next_entity_id: graph.header.next_entity_id,
126                next_edge_id: graph.header.next_edge_id,
127                next_episode_id: graph.header.next_episode_id,
128                file_path: Some(path),
129                bm25_index: Bm25Index::new(),
130                bm25_built: false,
131                pending_accesses: Vec::new(),
132                activation_states: HashMap::new(),
133                reconsolidation_states: HashMap::new(),
134                reconsolidation_params: ReconsolidationParams::default(),
135                dark_node_params: DarkNodeParams::default(),
136                fsrs_states: HashMap::new(),
137                fsrs_params: FsrsParams::default(),
138                consolidation_params: ConsolidationParams::default(),
139            })
140        } else {
141            Ok(Self {
142                file_path: Some(path),
143                ..Self::new(config)?
144            })
145        }
146    }
147
148    // --- Persistence ---
149
150    /// Flush pending access events to activation tracking.
151    fn flush_accesses(&mut self) {
152        if self.pending_accesses.is_empty() {
153            return;
154        }
155        let ids: Vec<EntityId> = self.pending_accesses.drain(..).collect();
156        for id in ids {
157            self.record_access(id);
158        }
159    }
160
161    /// Build the BM25 index from all entities if not already built.
162    fn ensure_bm25(&mut self) -> Result<()> {
163        if !self.bm25_built {
164            let entities = self.storage.scan_all_entities()?;
165            for entity in &entities {
166                let text = bm25::entity_text(&entity.name, &entity.properties);
167                self.bm25_index.index_document(entity.id.0 as u32, &text);
168            }
169            self.bm25_built = true;
170        }
171        Ok(())
172    }
173
174    /// Flush all data to the backing file.
175    ///
176    /// Writes to a temporary file first, then renames for crash safety.
177    /// Returns an error if this is an in-memory-only instance.
178    pub fn flush(&self) -> Result<()> {
179        let path = self.file_path.as_ref().ok_or(HoraError::InvalidFile {
180            reason: "cannot flush an in-memory-only instance",
181        })?;
182
183        let entities = self.storage.scan_all_entities()?;
184        let edges = self.storage.scan_all_edges()?;
185        let episodes = self.storage.scan_all_episodes()?;
186
187        let header = FileHeader {
188            embedding_dims: self.config.embedding_dims,
189            next_entity_id: self.next_entity_id,
190            next_edge_id: self.next_edge_id,
191            next_episode_id: self.next_episode_id,
192            entity_count: entities.len() as u32,
193            edge_count: edges.len() as u32,
194            episode_count: episodes.len() as u32,
195        };
196
197        // Write to .tmp then rename (crash-safe)
198        let tmp_path = path.with_extension("hora.tmp");
199        {
200            let file = std::fs::File::create(&tmp_path)?;
201            let mut writer = std::io::BufWriter::new(file);
202            format::serialize(&mut writer, &header, &entities, &edges, &episodes)?;
203        }
204        std::fs::rename(&tmp_path, path)?;
205
206        Ok(())
207    }
208
209    /// Copy the current state to a snapshot file.
210    ///
211    /// Flushes first if file-backed, then copies. For in-memory instances,
212    /// writes directly to the given path.
213    pub fn snapshot(&self, dest: impl AsRef<Path>) -> Result<()> {
214        let entities = self.storage.scan_all_entities()?;
215        let edges = self.storage.scan_all_edges()?;
216        let episodes = self.storage.scan_all_episodes()?;
217
218        let header = FileHeader {
219            embedding_dims: self.config.embedding_dims,
220            next_entity_id: self.next_entity_id,
221            next_edge_id: self.next_edge_id,
222            next_episode_id: self.next_episode_id,
223            entity_count: entities.len() as u32,
224            edge_count: edges.len() as u32,
225            episode_count: episodes.len() as u32,
226        };
227
228        let file = std::fs::File::create(dest)?;
229        let mut writer = std::io::BufWriter::new(file);
230        format::serialize(&mut writer, &header, &entities, &edges, &episodes)?;
231
232        Ok(())
233    }
234
235    // --- CRUD Entities ---
236
237    /// Add a new entity to the knowledge graph.
238    ///
239    /// If deduplication is enabled and a duplicate is detected among entities
240    /// of the same type, returns the existing entity's ID instead of creating
241    /// a new one. Detection uses: normalized name exact match, cosine embedding
242    /// similarity, and Jaccard token overlap (in that priority order).
243    pub fn add_entity(
244        &mut self,
245        entity_type: &str,
246        name: &str,
247        properties: Option<Properties>,
248        embedding: Option<&[f32]>,
249    ) -> Result<EntityId> {
250        // Validate embedding dimensions
251        if let Some(emb) = embedding {
252            if self.config.embedding_dims == 0 {
253                return Err(HoraError::DimensionMismatch {
254                    expected: 0,
255                    got: emb.len(),
256                });
257            }
258            if emb.len() != self.config.embedding_dims as usize {
259                return Err(HoraError::DimensionMismatch {
260                    expected: self.config.embedding_dims as usize,
261                    got: emb.len(),
262                });
263            }
264        }
265
266        // Deduplication check
267        if self.config.dedup.enabled {
268            let candidates = self.storage.scan_all_entities()?;
269            if let Some(existing_id) = crate::core::dedup::find_duplicate(
270                name,
271                embedding,
272                entity_type,
273                &candidates,
274                &self.config.dedup,
275            ) {
276                return Ok(existing_id);
277            }
278        }
279
280        let id = EntityId(self.next_entity_id);
281        self.next_entity_id += 1;
282
283        let entity = Entity {
284            id,
285            entity_type: entity_type.to_string(),
286            name: name.to_string(),
287            properties: properties.unwrap_or_default(),
288            embedding: embedding.map(|e| e.to_vec()),
289            created_at: now_millis(),
290        };
291
292        // Index for BM25 full-text search (skip if lazy rebuild pending)
293        if self.bm25_built {
294            let text = bm25::entity_text(&entity.name, &entity.properties);
295            self.bm25_index.index_document(id.0 as u32, &text);
296        }
297
298        // Initialize activation state
299        let now_secs = entity.created_at as f64 / 1000.0;
300        let mut act_state = ActivationState::new(now_secs);
301        act_state.record_access(now_secs);
302        self.activation_states.insert(id, act_state);
303
304        // Initialize reconsolidation state
305        self.reconsolidation_states
306            .insert(id, ReconsolidationState::new());
307
308        // Initialize FSRS state
309        self.fsrs_states.insert(
310            id,
311            FsrsState::new(now_secs, self.fsrs_params.initial_stability_days),
312        );
313
314        self.storage.put_entity(entity)?;
315        Ok(id)
316    }
317
318    /// Get an entity by ID. Returns `None` if not found.
319    ///
320    /// Side-effect: buffers an access event for ACT-R activation tracking.
321    /// The actual activation computation is deferred until needed.
322    pub fn get_entity(&mut self, id: EntityId) -> Result<Option<Entity>> {
323        let entity = self.storage.get_entity(id)?;
324        if entity.is_some() {
325            self.pending_accesses.push(id);
326        }
327        Ok(entity)
328    }
329
330    /// Update an entity. Only fields set to `Some` in the update are changed.
331    pub fn update_entity(&mut self, id: EntityId, update: EntityUpdate) -> Result<()> {
332        let mut entity = self
333            .storage
334            .get_entity(id)?
335            .ok_or(HoraError::EntityNotFound(id.0))?;
336
337        if let Some(name) = update.name {
338            entity.name = name;
339        }
340        if let Some(entity_type) = update.entity_type {
341            entity.entity_type = entity_type;
342        }
343        if let Some(properties) = update.properties {
344            entity.properties = properties;
345        }
346        if let Some(embedding) = update.embedding {
347            if self.config.embedding_dims == 0 {
348                return Err(HoraError::DimensionMismatch {
349                    expected: 0,
350                    got: embedding.len(),
351                });
352            }
353            if embedding.len() != self.config.embedding_dims as usize {
354                return Err(HoraError::DimensionMismatch {
355                    expected: self.config.embedding_dims as usize,
356                    got: embedding.len(),
357                });
358            }
359            entity.embedding = Some(embedding);
360        }
361
362        // Re-index for BM25 (skip if lazy rebuild pending)
363        if self.bm25_built {
364            let text = bm25::entity_text(&entity.name, &entity.properties);
365            self.bm25_index.index_document(id.0 as u32, &text);
366        }
367
368        self.storage.put_entity(entity)
369    }
370
371    /// Delete an entity and all its associated edges (cascade).
372    pub fn delete_entity(&mut self, id: EntityId) -> Result<()> {
373        if self.storage.get_entity(id)?.is_none() {
374            return Err(HoraError::EntityNotFound(id.0));
375        }
376
377        // Cascade: delete all edges connected to this entity
378        let edge_ids = self.storage.get_entity_edge_ids(id)?;
379        for edge_id in edge_ids {
380            self.storage.delete_edge(edge_id)?;
381        }
382
383        self.storage.delete_entity(id)?;
384        if self.bm25_built {
385            self.bm25_index.remove_document(id.0 as u32);
386        }
387        self.activation_states.remove(&id);
388        self.reconsolidation_states.remove(&id);
389        self.fsrs_states.remove(&id);
390        Ok(())
391    }
392
393    // --- CRUD Facts (edges) ---
394
395    /// Add a new fact (directed edge) between two entities.
396    ///
397    /// Returns the ID of the newly created fact. Both source and target
398    /// entities must exist.
399    pub fn add_fact(
400        &mut self,
401        source: EntityId,
402        target: EntityId,
403        relation: &str,
404        description: &str,
405        confidence: Option<f32>,
406    ) -> Result<EdgeId> {
407        // Verify both entities exist
408        if self.storage.get_entity(source)?.is_none() {
409            return Err(HoraError::EntityNotFound(source.0));
410        }
411        if self.storage.get_entity(target)?.is_none() {
412            return Err(HoraError::EntityNotFound(target.0));
413        }
414
415        let id = EdgeId(self.next_edge_id);
416        self.next_edge_id += 1;
417        let now = now_millis();
418
419        let edge = Edge {
420            id,
421            source,
422            target,
423            relation_type: relation.to_string(),
424            description: description.to_string(),
425            confidence: confidence.unwrap_or(1.0),
426            valid_at: now,
427            invalid_at: 0,
428            created_at: now,
429        };
430
431        self.storage.put_edge(edge)?;
432        Ok(id)
433    }
434
435    /// Get a fact by ID. Returns `None` if not found.
436    pub fn get_fact(&self, id: EdgeId) -> Result<Option<Edge>> {
437        self.storage.get_edge(id)
438    }
439
440    /// Update a fact. Only fields set to `Some` in the update are changed.
441    pub fn update_fact(&mut self, id: EdgeId, update: FactUpdate) -> Result<()> {
442        let mut edge = self
443            .storage
444            .get_edge(id)?
445            .ok_or(HoraError::EdgeNotFound(id.0))?;
446
447        if let Some(confidence) = update.confidence {
448            edge.confidence = confidence;
449        }
450        if let Some(description) = update.description {
451            edge.description = description;
452        }
453
454        self.storage.put_edge(edge)
455    }
456
457    /// Mark a fact as invalid (bi-temporal). The fact is NOT deleted —
458    /// it remains queryable with its validity window.
459    pub fn invalidate_fact(&mut self, id: EdgeId) -> Result<()> {
460        let mut edge = self
461            .storage
462            .get_edge(id)?
463            .ok_or(HoraError::EdgeNotFound(id.0))?;
464
465        if edge.invalid_at != 0 {
466            return Err(HoraError::AlreadyInvalidated(id.0));
467        }
468
469        edge.invalid_at = now_millis();
470        self.storage.put_edge(edge)
471    }
472
473    /// Physically delete a fact. Use `invalidate_fact` for bi-temporal soft-delete.
474    pub fn delete_fact(&mut self, id: EdgeId) -> Result<()> {
475        if !self.storage.delete_edge(id)? {
476            return Err(HoraError::EdgeNotFound(id.0));
477        }
478        Ok(())
479    }
480
481    /// Get all facts where the given entity is source or target.
482    pub fn get_entity_facts(&self, entity_id: EntityId) -> Result<Vec<Edge>> {
483        self.storage.get_entity_edges(entity_id)
484    }
485
486    // --- Graph Traversal ---
487
488    /// BFS traversal from a start entity up to the given depth.
489    ///
490    /// Returns IDs of all discovered entities and edges.
491    /// Depth 0 returns only the start entity (no edges).
492    pub fn traverse(&self, start: EntityId, opts: TraverseOpts) -> Result<TraverseResult> {
493        if self.storage.get_entity(start)?.is_none() {
494            return Err(HoraError::EntityNotFound(start.0));
495        }
496
497        let mut visited: HashSet<EntityId> = HashSet::new();
498        let mut result_entity_ids = vec![start];
499        let mut result_edge_ids: Vec<EdgeId> = Vec::new();
500        let mut seen_edges: HashSet<EdgeId> = HashSet::new();
501
502        visited.insert(start);
503
504        // BFS queue holds (entity_id, current_depth)
505        let mut queue: VecDeque<(EntityId, u32)> = VecDeque::new();
506        queue.push_back((start, 0));
507
508        while let Some((current_id, depth)) = queue.pop_front() {
509            if depth >= opts.depth {
510                continue;
511            }
512
513            let edges = self.storage.get_entity_edges(current_id)?;
514            for edge in edges {
515                if !seen_edges.insert(edge.id) {
516                    continue;
517                }
518
519                // The neighbor is whichever end is NOT current_id
520                let neighbor_id = if edge.source == current_id {
521                    edge.target
522                } else {
523                    edge.source
524                };
525
526                result_edge_ids.push(edge.id);
527
528                if visited.insert(neighbor_id) && self.storage.get_entity(neighbor_id)?.is_some() {
529                    result_entity_ids.push(neighbor_id);
530                    queue.push_back((neighbor_id, depth + 1));
531                }
532            }
533        }
534
535        Ok(TraverseResult {
536            entity_ids: result_entity_ids,
537            edge_ids: result_edge_ids,
538        })
539    }
540
541    /// Get all direct neighbor entity IDs (connected via any edge).
542    pub fn neighbors(&self, entity_id: EntityId) -> Result<Vec<EntityId>> {
543        let edges = self.storage.get_entity_edges(entity_id)?;
544        let mut neighbor_ids: HashSet<EntityId> = HashSet::new();
545
546        for edge in &edges {
547            if edge.source == entity_id {
548                neighbor_ids.insert(edge.target);
549            } else {
550                neighbor_ids.insert(edge.source);
551            }
552        }
553
554        // Remove self (possible with self-loops)
555        neighbor_ids.remove(&entity_id);
556        Ok(neighbor_ids.into_iter().collect())
557    }
558
559    /// Timeline of all facts involving an entity, sorted by `valid_at`.
560    pub fn timeline(&self, entity_id: EntityId) -> Result<Vec<Edge>> {
561        let mut edges = self.storage.get_entity_edges(entity_id)?;
562        edges.sort_by_key(|e| e.valid_at);
563        Ok(edges)
564    }
565
566    /// All facts valid at a given point in time.
567    ///
568    /// A fact is valid at time `t` if `valid_at <= t` and
569    /// (`invalid_at == 0` or `invalid_at > t`).
570    pub fn facts_at(&self, t: i64) -> Result<Vec<Edge>> {
571        let all = self.storage.scan_all_edges()?;
572        let valid: Vec<Edge> = all
573            .into_iter()
574            .filter(|e| e.valid_at <= t && (e.invalid_at == 0 || e.invalid_at > t))
575            .collect();
576        Ok(valid)
577    }
578
579    // --- Vector Search ---
580
581    /// Brute-force vector search: find the `k` most similar entities by cosine similarity.
582    ///
583    /// Requires `embedding_dims > 0` in the config. Entities without embeddings
584    /// are silently skipped. The query embedding must match `embedding_dims` in length.
585    pub fn vector_search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
586        if self.config.embedding_dims == 0 {
587            return Err(HoraError::DimensionMismatch {
588                expected: 0,
589                got: query.len(),
590            });
591        }
592        if query.len() != self.config.embedding_dims as usize {
593            return Err(HoraError::DimensionMismatch {
594                expected: self.config.embedding_dims as usize,
595                got: query.len(),
596            });
597        }
598
599        let entities = self.storage.scan_all_entities()?;
600
601        // Collect (id, embedding_slice) pairs, skip entities without embeddings
602        let with_embeddings: Vec<(EntityId, &[f32])> = entities
603            .iter()
604            .filter_map(|e| e.embedding.as_ref().map(|emb| (e.id, emb.as_slice())))
605            .collect();
606
607        Ok(search::vector::top_k_brute_force(
608            query,
609            &with_embeddings,
610            k,
611        ))
612    }
613
614    // --- Text Search (BM25) ---
615
616    /// Full-text search using BM25+ scoring over entity names and string properties.
617    ///
618    /// Returns the top `k` matching entities. Entities without indexable text
619    /// are invisible to BM25.
620    pub fn text_search(&mut self, query: &str, k: usize) -> Result<Vec<SearchHit>> {
621        self.ensure_bm25()?;
622        Ok(self.bm25_index.search(query, k))
623    }
624
625    // --- Hybrid Search ---
626
627    /// Hybrid search combining vector similarity and BM25 full-text via RRF fusion.
628    ///
629    /// Provide `query_text` for BM25, `query_embedding` for vector search, or both.
630    /// When both are provided, results are fused using Reciprocal Rank Fusion.
631    /// When only one is provided, that leg runs alone.
632    /// Returns empty if neither is provided.
633    pub fn search(
634        &mut self,
635        query_text: Option<&str>,
636        query_embedding: Option<&[f32]>,
637        opts: SearchOpts,
638    ) -> Result<Vec<SearchHit>> {
639        let candidate_k = opts.top_k * 3;
640
641        // Vector leg (skip if embedding_dims=0 or no embedding provided)
642        let vec_results = if let Some(emb) = query_embedding {
643            if self.config.embedding_dims > 0 && emb.len() == self.config.embedding_dims as usize {
644                Some(self.vector_search(emb, candidate_k)?)
645            } else {
646                None
647            }
648        } else {
649            None
650        };
651
652        // BM25 leg
653        let bm25_results = if let Some(text) = query_text {
654            self.ensure_bm25()?;
655            let results = self.bm25_index.search(text, candidate_k);
656            if results.is_empty() {
657                None
658            } else {
659                Some(results)
660            }
661        } else {
662            None
663        };
664
665        let mut results =
666            search::hybrid::rrf_fuse(vec_results.as_deref(), bm25_results.as_deref(), opts.top_k);
667
668        // Filter out dark nodes unless include_dark is set
669        if !opts.include_dark {
670            results.retain(|hit| {
671                !self
672                    .reconsolidation_states
673                    .get(&hit.entity_id)
674                    .is_some_and(|r| r.is_dark())
675            });
676        }
677
678        // Side-effect: record access for returned results
679        for hit in &results {
680            self.record_access(hit.entity_id);
681        }
682
683        Ok(results)
684    }
685
686    // --- Activation (ACT-R) ---
687
688    /// Get the current ACT-R base-level activation for an entity.
689    ///
690    /// Returns `f64::NEG_INFINITY` if the entity has never been accessed,
691    /// or `None` if the entity doesn't exist.
692    pub fn get_activation(&mut self, id: EntityId) -> Option<f64> {
693        self.flush_accesses();
694        let now = now_millis() as f64 / 1000.0;
695        self.activation_states
696            .get_mut(&id)
697            .map(|state| state.compute_activation(now))
698    }
699
700    /// Record an access on an entity for ACT-R activation tracking.
701    ///
702    /// Called automatically by `get_entity()` and `search()`. Can also be
703    /// called directly for external access events.
704    pub fn record_access(&mut self, id: EntityId) {
705        let now = now_millis() as f64 / 1000.0;
706        if let Some(act_state) = self.activation_states.get_mut(&id) {
707            let activation = act_state.compute_activation(now);
708            act_state.record_access(now);
709
710            // Trigger reconsolidation check
711            if let Some(recon) = self.reconsolidation_states.get_mut(&id) {
712                recon.on_reactivation(activation, now, &self.reconsolidation_params);
713            }
714
715            // FSRS: record review with reconsolidation boost
716            let boost = self
717                .reconsolidation_states
718                .get(&id)
719                .map(|r| r.stability_multiplier())
720                .unwrap_or(1.0);
721            if let Some(fsrs) = self.fsrs_states.get_mut(&id) {
722                fsrs.record_review(now, boost, &self.fsrs_params);
723            }
724        }
725    }
726
727    /// Get the current reconsolidation phase for an entity.
728    ///
729    /// Resolves any pending time-based transitions before returning.
730    /// Returns `None` if the entity doesn't exist.
731    pub fn get_memory_phase(&mut self, id: EntityId) -> Option<&MemoryPhase> {
732        let now = now_millis() as f64 / 1000.0;
733        if let Some(recon) = self.reconsolidation_states.get_mut(&id) {
734            recon.tick(now, &self.reconsolidation_params);
735            Some(recon.phase())
736        } else {
737            None
738        }
739    }
740
741    /// Get the cumulative stability multiplier for an entity.
742    ///
743    /// Starts at 1.0, increases by `restabilization_boost` (default 1.2×)
744    /// each time the entity completes a reconsolidation cycle.
745    pub fn get_stability_multiplier(&mut self, id: EntityId) -> Option<f64> {
746        let now = now_millis() as f64 / 1000.0;
747        if let Some(recon) = self.reconsolidation_states.get_mut(&id) {
748            recon.tick(now, &self.reconsolidation_params);
749            Some(recon.stability_multiplier())
750        } else {
751            None
752        }
753    }
754
755    // --- FSRS Scheduling ---
756
757    /// Get the current retrievability for an entity (0.0 to 1.0).
758    ///
759    /// R = 1.0 immediately after review, R → 0.0 as time passes.
760    /// Returns `None` if the entity doesn't exist.
761    pub fn get_retrievability(&self, id: EntityId) -> Option<f64> {
762        let now = now_millis() as f64 / 1000.0;
763        self.fsrs_states
764            .get(&id)
765            .map(|fsrs| fsrs.current_retrievability(now, &self.fsrs_params))
766    }
767
768    /// Get the optimal next review interval in days for an entity.
769    ///
770    /// Uses the configured `desired_retention` (default 0.9).
771    /// Returns `None` if the entity doesn't exist.
772    pub fn get_next_review_days(&self, id: EntityId) -> Option<f64> {
773        self.fsrs_states.get(&id).map(|fsrs| {
774            fsrs.next_review_interval_days(self.fsrs_params.desired_retention, &self.fsrs_params)
775        })
776    }
777
778    /// Get the current FSRS stability in days for an entity.
779    ///
780    /// Returns `None` if the entity doesn't exist.
781    pub fn get_fsrs_stability(&self, id: EntityId) -> Option<f64> {
782        self.fsrs_states.get(&id).map(|fsrs| fsrs.stability_days())
783    }
784
785    // --- Dark Nodes ---
786
787    /// Scan all entities and mark those below activation threshold as Dark.
788    ///
789    /// An entity becomes Dark when:
790    /// - Its activation is below `silencing_threshold` (default -2.0)
791    /// - Its last access was more than `silencing_delay_secs` ago (default 7 days)
792    /// - It is currently in Stable state (not Labile/Restabilizing/already Dark)
793    ///
794    /// Returns the number of entities newly marked as Dark.
795    pub fn dark_node_pass(&mut self) -> usize {
796        let now = now_millis() as f64 / 1000.0;
797        let params = &self.dark_node_params;
798        let recon_params = &self.reconsolidation_params;
799
800        let mut to_darken: Vec<EntityId> = Vec::new();
801
802        for (&id, act_state) in &mut self.activation_states {
803            let activation = act_state.compute_activation(now);
804
805            // Check activation threshold
806            if activation >= params.silencing_threshold {
807                continue;
808            }
809
810            // Check delay since last access
811            let last_access = act_state.last_access_time().unwrap_or(0.0);
812            if now - last_access < params.silencing_delay_secs {
813                continue;
814            }
815
816            // Only silence Stable entities (not Labile/Restabilizing/already Dark)
817            if let Some(recon) = self.reconsolidation_states.get_mut(&id) {
818                recon.tick(now, recon_params);
819                if *recon.phase() == MemoryPhase::Stable {
820                    to_darken.push(id);
821                }
822            }
823        }
824
825        for id in &to_darken {
826            if let Some(recon) = self.reconsolidation_states.get_mut(id) {
827                recon.mark_dark(now);
828            }
829        }
830
831        to_darken.len()
832    }
833
834    /// Attempt to recover a Dark entity via strong external reactivation.
835    ///
836    /// If the entity is Dark, it transitions to Labile (for re-encoding)
837    /// and a record_access is applied. Returns `true` if recovery occurred.
838    pub fn attempt_recovery(&mut self, id: EntityId) -> bool {
839        let now = now_millis() as f64 / 1000.0;
840        let recovered = self
841            .reconsolidation_states
842            .get_mut(&id)
843            .is_some_and(|recon| recon.recover(now));
844
845        if recovered {
846            // Record the strong reactivation
847            if let Some(act_state) = self.activation_states.get_mut(&id) {
848                act_state.record_access(now);
849            }
850        }
851
852        recovered
853    }
854
855    /// List all entity IDs currently in Dark state.
856    pub fn dark_nodes(&mut self) -> Vec<EntityId> {
857        let now = now_millis() as f64 / 1000.0;
858        self.reconsolidation_states
859            .iter_mut()
860            .filter_map(|(&id, recon)| {
861                recon.tick(now, &self.reconsolidation_params);
862                if recon.is_dark() {
863                    Some(id)
864                } else {
865                    None
866                }
867            })
868            .collect()
869    }
870
871    /// List dark entities eligible for garbage collection (dark > gc_eligible_after_secs).
872    pub fn gc_candidates(&mut self) -> Vec<EntityId> {
873        let now = now_millis() as f64 / 1000.0;
874        let gc_after = self.dark_node_params.gc_eligible_after_secs;
875
876        self.reconsolidation_states
877            .iter_mut()
878            .filter_map(|(&id, recon)| {
879                recon.tick(now, &self.reconsolidation_params);
880                match recon.phase() {
881                    MemoryPhase::Dark { silenced_at } => {
882                        if now - silenced_at >= gc_after {
883                            Some(id)
884                        } else {
885                            None
886                        }
887                    }
888                    _ => None,
889                }
890            })
891            .collect()
892    }
893
894    // --- Consolidation (SHY) ---
895
896    /// Apply SHY homeostatic downscaling to all entity activations.
897    ///
898    /// Multiplies every entity's activation score by `factor` (default 0.78).
899    /// This is cumulative and idempotent-safe: two calls produce `factor²`.
900    /// Affects both positive and negative activations (amplitude reduction).
901    ///
902    /// Returns the number of entities downscaled.
903    pub fn shy_downscaling(&mut self, factor: f64) -> usize {
904        let count = self.activation_states.len();
905        for state in self.activation_states.values_mut() {
906            state.apply_shy_downscaling(factor);
907        }
908        count
909    }
910
911    /// Interleaved replay: re-activate entities from a mix of recent and old episodes.
912    ///
913    /// Selects up to `max_replay_items` episodes, split by `recent_ratio` (default 70%
914    /// recent, 30% older). For each selected episode, calls `record_access()` on every
915    /// referenced entity that still exists.
916    ///
917    /// Episodes are split at the median `created_at` timestamp: those above median are
918    /// "recent", the rest are "older". This is deterministic (no RNG required).
919    pub fn interleaved_replay(&mut self) -> Result<ReplayStats> {
920        let params = &self.consolidation_params;
921        let max = params.max_replay_items;
922        let ratio = params.recent_ratio.clamp(0.0, 1.0);
923
924        let mut all_episodes = self.storage.scan_all_episodes()?;
925        if all_episodes.is_empty() || max == 0 {
926            return Ok(ReplayStats {
927                episodes_replayed: 0,
928                entities_reactivated: 0,
929            });
930        }
931
932        // Sort by created_at ascending
933        all_episodes.sort_by_key(|e| e.created_at);
934
935        // Split at median into older (first half) and recent (second half)
936        let mid = all_episodes.len() / 2;
937        let (older, recent) = all_episodes.split_at(mid);
938
939        // Budget allocation
940        let recent_budget = ((max as f64) * ratio).ceil() as usize;
941        let older_budget = max.saturating_sub(recent_budget);
942
943        // Take from the end of each group (most recent within each group)
944        let selected_recent: Vec<_> = recent.iter().rev().take(recent_budget).collect();
945        let selected_older: Vec<_> = older.iter().rev().take(older_budget).collect();
946
947        let mut episodes_replayed = 0;
948        let mut entities_reactivated = 0;
949
950        for ep in selected_recent.iter().chain(selected_older.iter()) {
951            episodes_replayed += 1;
952            for &entity_id in &ep.entity_ids {
953                // Only reactivate if the entity still has an activation state
954                if self.activation_states.contains_key(&entity_id) {
955                    self.record_access(entity_id);
956                    entities_reactivated += 1;
957                }
958            }
959        }
960
961        Ok(ReplayStats {
962            episodes_replayed,
963            entities_reactivated,
964        })
965    }
966
967    /// CLS transfer: extract recurring episodic patterns into semantic facts.
968    ///
969    /// Scans episodes with `consolidation_count >= cls_threshold`. For each,
970    /// collects the referenced facts and groups them by (source, relation, target).
971    /// Triplets seen in >= `cls_threshold` distinct episodes become semantic facts
972    /// (or get their confidence reinforced if already existing).
973    ///
974    /// Each processed episode gets its `consolidation_count` incremented.
975    pub fn cls_transfer(&mut self) -> Result<ClsStats> {
976        let threshold = self.consolidation_params.cls_threshold;
977        let all_episodes = self.storage.scan_all_episodes()?;
978
979        // Filter eligible episodes
980        let eligible: Vec<_> = all_episodes
981            .iter()
982            .filter(|ep| ep.consolidation_count >= threshold)
983            .collect();
984
985        if eligible.is_empty() {
986            return Ok(ClsStats {
987                episodes_processed: 0,
988                facts_created: 0,
989                facts_reinforced: 0,
990            });
991        }
992
993        // Count triplets across eligible episodes: (source, relation, target) → count
994        let mut triplet_counts: HashMap<(EntityId, String, EntityId), u32> = HashMap::new();
995
996        for ep in &eligible {
997            // Deduplicate triplets within a single episode
998            let mut seen_in_ep: HashSet<(EntityId, String, EntityId)> = HashSet::new();
999            for &fact_id in &ep.fact_ids {
1000                if let Some(edge) = self.storage.get_edge(fact_id)? {
1001                    let key = (edge.source, edge.relation_type.clone(), edge.target);
1002                    if seen_in_ep.insert(key.clone()) {
1003                        *triplet_counts.entry(key).or_insert(0) += 1;
1004                    }
1005                }
1006            }
1007        }
1008
1009        let mut facts_created = 0_usize;
1010        let mut facts_reinforced = 0_usize;
1011
1012        // For triplets seen in >= threshold episodes, create or reinforce semantic fact
1013        for ((source, relation, target), count) in &triplet_counts {
1014            if *count < threshold {
1015                continue;
1016            }
1017
1018            // Check if a valid edge with same triplet already exists
1019            let existing_edges = self.storage.get_entity_edges(*source)?;
1020            let existing = existing_edges
1021                .iter()
1022                .find(|e| e.target == *target && e.relation_type == *relation && e.invalid_at == 0);
1023
1024            if let Some(edge) = existing {
1025                // Reinforce: bump confidence (cap at 1.0)
1026                let new_confidence = (edge.confidence + 0.1).min(1.0);
1027                self.storage.put_edge(Edge {
1028                    confidence: new_confidence,
1029                    ..edge.clone()
1030                })?;
1031                facts_reinforced += 1;
1032            } else {
1033                // Check both entities still exist before creating
1034                if self.storage.get_entity(*source)?.is_some()
1035                    && self.storage.get_entity(*target)?.is_some()
1036                {
1037                    let id = EdgeId(self.next_edge_id);
1038                    self.next_edge_id += 1;
1039                    let now = crate::core::types::now_millis();
1040                    let edge = Edge {
1041                        id,
1042                        source: *source,
1043                        target: *target,
1044                        relation_type: relation.clone(),
1045                        description: format!("semantic: consolidated from {count} episodes"),
1046                        confidence: 0.9,
1047                        valid_at: now,
1048                        invalid_at: 0,
1049                        created_at: now,
1050                    };
1051                    self.storage.put_edge(edge)?;
1052                    facts_created += 1;
1053                }
1054            }
1055        }
1056
1057        // Increment consolidation_count on processed episodes
1058        let episodes_processed = eligible.len();
1059        for ep in &eligible {
1060            self.storage
1061                .update_episode_consolidation(ep.id, ep.consolidation_count + 1)?;
1062        }
1063
1064        Ok(ClsStats {
1065            episodes_processed,
1066            facts_created,
1067            facts_reinforced,
1068        })
1069    }
1070
1071    /// Memory linking: create temporal links between entities co-created within a time window.
1072    ///
1073    /// Scans all entities sorted by `created_at`. For each pair within `linking_window_ms`
1074    /// (default 6h), creates bidirectional "temporally_linked" edges (A→B and B→A).
1075    /// If the link already exists, reinforces its confidence (+0.1, capped at 1.0).
1076    pub fn memory_linking(&mut self) -> Result<LinkingStats> {
1077        let window = self.consolidation_params.linking_window_ms;
1078        let mut entities = self.storage.scan_all_entities()?;
1079
1080        if entities.len() < 2 {
1081            return Ok(LinkingStats {
1082                links_created: 0,
1083                links_reinforced: 0,
1084            });
1085        }
1086
1087        entities.sort_by_key(|e| e.created_at);
1088
1089        // Collect existing "temporally_linked" edges into a lookup set
1090        let all_edges = self.storage.scan_all_edges()?;
1091        let mut existing_links: HashMap<(EntityId, EntityId), EdgeId> = HashMap::new();
1092        for edge in &all_edges {
1093            if edge.relation_type == "temporally_linked" && edge.invalid_at == 0 {
1094                existing_links.insert((edge.source, edge.target), edge.id);
1095            }
1096        }
1097
1098        let mut links_created = 0_usize;
1099        let mut links_reinforced = 0_usize;
1100
1101        let max_neighbors = self.consolidation_params.linking_max_neighbors;
1102
1103        // Sliding window: for each entity, pair with subsequent entities within window
1104        // O(n·k) cap via max_neighbors limit per entity
1105        for i in 0..entities.len() {
1106            for ej in entities[(i + 1)..].iter().take(max_neighbors) {
1107                let delta = ej.created_at - entities[i].created_at;
1108                if delta >= window {
1109                    break; // sorted, so all further will also exceed window
1110                }
1111
1112                let a = entities[i].id;
1113                let b = ej.id;
1114
1115                // Direction A→B
1116                if let Some(&edge_id) = existing_links.get(&(a, b)) {
1117                    if let Some(edge) = self.storage.get_edge(edge_id)? {
1118                        let new_conf = (edge.confidence + 0.1).min(1.0);
1119                        self.storage.put_edge(Edge {
1120                            confidence: new_conf,
1121                            ..edge
1122                        })?;
1123                        links_reinforced += 1;
1124                    }
1125                } else {
1126                    let id = EdgeId(self.next_edge_id);
1127                    self.next_edge_id += 1;
1128                    let now = crate::core::types::now_millis();
1129                    self.storage.put_edge(Edge {
1130                        id,
1131                        source: a,
1132                        target: b,
1133                        relation_type: "temporally_linked".to_string(),
1134                        description: String::new(),
1135                        confidence: 0.5,
1136                        valid_at: now,
1137                        invalid_at: 0,
1138                        created_at: now,
1139                    })?;
1140                    links_created += 1;
1141                }
1142
1143                // Direction B→A
1144                if let Some(&edge_id) = existing_links.get(&(b, a)) {
1145                    if let Some(edge) = self.storage.get_edge(edge_id)? {
1146                        let new_conf = (edge.confidence + 0.1).min(1.0);
1147                        self.storage.put_edge(Edge {
1148                            confidence: new_conf,
1149                            ..edge
1150                        })?;
1151                        links_reinforced += 1;
1152                    }
1153                } else {
1154                    let id = EdgeId(self.next_edge_id);
1155                    self.next_edge_id += 1;
1156                    let now = crate::core::types::now_millis();
1157                    self.storage.put_edge(Edge {
1158                        id,
1159                        source: b,
1160                        target: a,
1161                        relation_type: "temporally_linked".to_string(),
1162                        description: String::new(),
1163                        confidence: 0.5,
1164                        valid_at: now,
1165                        invalid_at: 0,
1166                        created_at: now,
1167                    })?;
1168                    links_created += 1;
1169                }
1170            }
1171        }
1172
1173        Ok(LinkingStats {
1174            links_created,
1175            links_reinforced,
1176        })
1177    }
1178
1179    /// Run a full dream cycle: the 6-step consolidation pipeline.
1180    ///
1181    /// Steps (in order):
1182    /// 1. **SHY downscaling** — reduce all activation scores
1183    /// 2. **Interleaved replay** — reactivate entities from mixed episodes
1184    /// 3. **CLS transfer** — extract recurring patterns into semantic facts
1185    /// 4. **Memory linking** — create temporal co-creation edges
1186    /// 5. **Dark check** — silence low-activation entities
1187    /// 6. **GC** (optional) — delete GC-eligible dark entities
1188    ///
1189    /// Each step can be enabled/disabled via `DreamCycleConfig`.
1190    pub fn dream_cycle(&mut self, config: &DreamCycleConfig) -> Result<DreamCycleStats> {
1191        self.flush_accesses();
1192        // 1. SHY
1193        let entities_downscaled = if config.shy {
1194            self.shy_downscaling(self.consolidation_params.shy_factor)
1195        } else {
1196            0
1197        };
1198
1199        // 2. Replay
1200        let replay = if config.replay {
1201            self.interleaved_replay()?
1202        } else {
1203            ReplayStats {
1204                episodes_replayed: 0,
1205                entities_reactivated: 0,
1206            }
1207        };
1208
1209        // 3. CLS
1210        let cls = if config.cls {
1211            self.cls_transfer()?
1212        } else {
1213            ClsStats {
1214                episodes_processed: 0,
1215                facts_created: 0,
1216                facts_reinforced: 0,
1217            }
1218        };
1219
1220        // 4. Memory linking
1221        let linking = if config.linking {
1222            self.memory_linking()?
1223        } else {
1224            LinkingStats {
1225                links_created: 0,
1226                links_reinforced: 0,
1227            }
1228        };
1229
1230        // 5. Dark check
1231        let dark_nodes_marked = if config.dark_check {
1232            self.dark_node_pass()
1233        } else {
1234            0
1235        };
1236
1237        // 6. GC (destructive, opt-in)
1238        let gc_deleted = if config.gc {
1239            let candidates = self.gc_candidates();
1240            let count = candidates.len();
1241            for id in candidates {
1242                let _ = self.delete_entity(id);
1243            }
1244            count
1245        } else {
1246            0
1247        };
1248
1249        Ok(DreamCycleStats {
1250            entities_downscaled,
1251            replay,
1252            cls,
1253            linking,
1254            dark_nodes_marked,
1255            gc_deleted,
1256        })
1257    }
1258
1259    // --- Spreading Activation ---
1260
1261    /// Spread activation from source entities through the knowledge graph.
1262    ///
1263    /// Uses ACT-R fan effect: `S_ji = S_max - ln(fan)`. High-fan nodes
1264    /// inhibit spreading (negative activation when fan > e^S_max ≈ 5).
1265    ///
1266    /// Returns accumulated activation per entity (can be negative for inhibition).
1267    pub fn spread_activation(
1268        &self,
1269        sources: &[(EntityId, f64)],
1270        params: &SpreadingParams,
1271    ) -> Result<std::collections::HashMap<EntityId, f64>> {
1272        let storage = &self.storage;
1273        let activations = crate::memory::spreading::spread_activation(
1274            sources,
1275            |id| {
1276                storage
1277                    .get_entity_edges(id)
1278                    .unwrap_or_default()
1279                    .iter()
1280                    .map(|e| if e.source == id { e.target } else { e.source })
1281                    .collect::<HashSet<_>>()
1282                    .into_iter()
1283                    .collect()
1284            },
1285            params,
1286        );
1287        Ok(activations)
1288    }
1289
1290    // --- Episodes ---
1291
1292    /// Record an episode (interaction snapshot).
1293    pub fn add_episode(
1294        &mut self,
1295        source: EpisodeSource,
1296        session_id: &str,
1297        entity_ids: &[EntityId],
1298        fact_ids: &[EdgeId],
1299    ) -> Result<u64> {
1300        let id = self.next_episode_id;
1301        self.next_episode_id += 1;
1302
1303        let episode = Episode {
1304            id,
1305            source,
1306            session_id: session_id.to_string(),
1307            entity_ids: entity_ids.to_vec(),
1308            fact_ids: fact_ids.to_vec(),
1309            created_at: now_millis(),
1310            consolidation_count: 0,
1311        };
1312
1313        self.storage.put_episode(episode)?;
1314        Ok(id)
1315    }
1316
1317    /// Get an episode by ID.
1318    pub fn get_episode(&self, id: u64) -> Result<Option<Episode>> {
1319        self.storage.get_episode(id)
1320    }
1321
1322    /// Get all episodes, optionally filtered.
1323    ///
1324    /// Filters:
1325    /// - `session_id`: only episodes from this session
1326    /// - `source`: only episodes from this source type
1327    /// - `since`/`until`: epoch millis time range on `created_at`
1328    pub fn get_episodes(
1329        &self,
1330        session_id: Option<&str>,
1331        source: Option<EpisodeSource>,
1332        since: Option<i64>,
1333        until: Option<i64>,
1334    ) -> Result<Vec<Episode>> {
1335        let mut episodes = self.storage.scan_all_episodes()?;
1336
1337        if let Some(sid) = session_id {
1338            episodes.retain(|e| e.session_id == sid);
1339        }
1340        if let Some(src) = source {
1341            episodes.retain(|e| e.source == src);
1342        }
1343        if let Some(t) = since {
1344            episodes.retain(|e| e.created_at >= t);
1345        }
1346        if let Some(t) = until {
1347            episodes.retain(|e| e.created_at <= t);
1348        }
1349
1350        episodes.sort_by_key(|e| e.created_at);
1351        Ok(episodes)
1352    }
1353
1354    /// Increment the consolidation count for an episode.
1355    pub fn increment_consolidation(&mut self, episode_id: u64) -> Result<bool> {
1356        if let Some(ep) = self.storage.get_episode(episode_id)? {
1357            self.storage
1358                .update_episode_consolidation(episode_id, ep.consolidation_count + 1)
1359        } else {
1360            Ok(false)
1361        }
1362    }
1363
1364    // --- Stats ---
1365
1366    /// Get summary statistics about the knowledge graph.
1367    pub fn stats(&self) -> Result<StorageStats> {
1368        Ok(self.storage.stats())
1369    }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375
1376    #[test]
1377    fn test_entity_creation() {
1378        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1379        let id = hora.add_entity("project", "hora", None, None).unwrap();
1380        let entity = hora.get_entity(id).unwrap().unwrap();
1381        assert_eq!(entity.name, "hora");
1382        assert_eq!(entity.entity_type, "project");
1383    }
1384
1385    #[test]
1386    fn test_edge_creation() {
1387        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1388        let a = hora.add_entity("project", "hora", None, None).unwrap();
1389        let b = hora.add_entity("language", "Rust", None, None).unwrap();
1390        let _fact = hora
1391            .add_fact(a, b, "built_with", "hora is built with Rust", None)
1392            .unwrap();
1393        let edges = hora.get_entity_facts(a).unwrap();
1394        assert_eq!(edges.len(), 1);
1395    }
1396
1397    #[test]
1398    fn test_entity_id_auto_increment() {
1399        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1400        let id1 = hora.add_entity("a", "first", None, None).unwrap();
1401        let id2 = hora.add_entity("b", "second", None, None).unwrap();
1402        assert_ne!(id1, id2);
1403        assert_eq!(id1.0 + 1, id2.0);
1404    }
1405
1406    #[test]
1407    fn test_entity_not_found() {
1408        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1409        let result = hora.get_entity(EntityId(999)).unwrap();
1410        assert!(result.is_none());
1411    }
1412
1413    #[test]
1414    fn test_fact_references_valid_entities() {
1415        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1416        let a = hora.add_entity("project", "hora", None, None).unwrap();
1417        let result = hora.add_fact(a, EntityId(999), "rel", "desc", None);
1418        assert!(result.is_err());
1419    }
1420
1421    #[test]
1422    fn test_edge_bidirectional_lookup() {
1423        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1424        let a = hora.add_entity("project", "hora", None, None).unwrap();
1425        let b = hora.add_entity("language", "Rust", None, None).unwrap();
1426        hora.add_fact(a, b, "built_with", "hora is built with Rust", None)
1427            .unwrap();
1428
1429        // Edge visible from both source and target
1430        let from_a = hora.get_entity_facts(a).unwrap();
1431        let from_b = hora.get_entity_facts(b).unwrap();
1432        assert_eq!(from_a.len(), 1);
1433        assert_eq!(from_b.len(), 1);
1434        assert_eq!(from_a[0].id, from_b[0].id);
1435    }
1436
1437    #[test]
1438    fn test_edge_temporal_defaults() {
1439        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1440        let a = hora.add_entity("a", "x", None, None).unwrap();
1441        let b = hora.add_entity("b", "y", None, None).unwrap();
1442        let eid = hora.add_fact(a, b, "rel", "desc", None).unwrap();
1443        let edge = hora.get_fact(eid).unwrap().unwrap();
1444
1445        assert!(edge.valid_at > 0);
1446        assert_eq!(edge.invalid_at, 0); // still valid
1447        assert!(edge.created_at > 0);
1448        assert_eq!(edge.confidence, 1.0);
1449    }
1450
1451    #[test]
1452    fn test_embedding_dimension_mismatch() {
1453        let config = HoraConfig {
1454            embedding_dims: 4,
1455            dedup: DedupConfig::disabled(),
1456        };
1457        let mut hora = HoraCore::new(config).unwrap();
1458        let wrong_dims = vec![1.0, 2.0]; // 2 instead of 4
1459        let result = hora.add_entity("a", "x", None, Some(&wrong_dims));
1460        assert!(result.is_err());
1461    }
1462
1463    #[test]
1464    fn test_embedding_when_dims_zero() {
1465        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1466        let emb = vec![1.0, 2.0, 3.0];
1467        let result = hora.add_entity("a", "x", None, Some(&emb));
1468        assert!(result.is_err());
1469    }
1470
1471    #[test]
1472    fn test_embedding_correct_dims() {
1473        let config = HoraConfig {
1474            embedding_dims: 3,
1475            dedup: DedupConfig::disabled(),
1476        };
1477        let mut hora = HoraCore::new(config).unwrap();
1478        let emb = vec![1.0, 2.0, 3.0];
1479        let id = hora.add_entity("a", "x", None, Some(&emb)).unwrap();
1480        let entity = hora.get_entity(id).unwrap().unwrap();
1481        assert_eq!(entity.embedding.as_ref().unwrap().len(), 3);
1482    }
1483
1484    #[test]
1485    fn test_properties() {
1486        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1487        let mut props = Properties::new();
1488        props.insert(
1489            "language".to_string(),
1490            PropertyValue::String("Rust".to_string()),
1491        );
1492        props.insert("stars".to_string(), PropertyValue::Int(42));
1493
1494        let id = hora
1495            .add_entity("project", "hora", Some(props), None)
1496            .unwrap();
1497        let entity = hora.get_entity(id).unwrap().unwrap();
1498        assert_eq!(
1499            entity.properties.get("language"),
1500            Some(&PropertyValue::String("Rust".to_string()))
1501        );
1502        assert_eq!(
1503            entity.properties.get("stars"),
1504            Some(&PropertyValue::Int(42))
1505        );
1506    }
1507
1508    #[test]
1509    fn test_episode_creation() {
1510        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1511        let a = hora.add_entity("project", "hora", None, None).unwrap();
1512        let b = hora.add_entity("language", "Rust", None, None).unwrap();
1513        let fact = hora.add_fact(a, b, "built_with", "desc", None).unwrap();
1514
1515        let ep_id = hora
1516            .add_episode(EpisodeSource::Conversation, "sess-1", &[a, b], &[fact])
1517            .unwrap();
1518        assert_eq!(ep_id, 1);
1519    }
1520
1521    #[test]
1522    fn test_stats() {
1523        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1524        let a = hora.add_entity("project", "hora", None, None).unwrap();
1525        let b = hora.add_entity("language", "Rust", None, None).unwrap();
1526        hora.add_fact(a, b, "built_with", "desc", None).unwrap();
1527
1528        let stats = hora.stats().unwrap();
1529        assert_eq!(stats.entities, 2);
1530        assert_eq!(stats.edges, 1);
1531        assert_eq!(stats.episodes, 0);
1532    }
1533
1534    #[test]
1535    fn test_entity_id_display() {
1536        let id = EntityId(42);
1537        assert_eq!(format!("{}", id), "entity:42");
1538    }
1539
1540    #[test]
1541    fn test_edge_id_display() {
1542        let id = EdgeId(7);
1543        assert_eq!(format!("{}", id), "edge:7");
1544    }
1545
1546    // --- v0.1b tests ---
1547
1548    #[test]
1549    fn test_update_entity() {
1550        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1551        let id = hora.add_entity("project", "hora", None, None).unwrap();
1552
1553        hora.update_entity(
1554            id,
1555            EntityUpdate {
1556                name: Some("hora-graph-core".to_string()),
1557                ..Default::default()
1558            },
1559        )
1560        .unwrap();
1561
1562        let entity = hora.get_entity(id).unwrap().unwrap();
1563        assert_eq!(entity.name, "hora-graph-core");
1564        assert_eq!(entity.entity_type, "project"); // unchanged
1565    }
1566
1567    #[test]
1568    fn test_update_entity_not_found() {
1569        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1570        let result = hora.update_entity(EntityId(999), EntityUpdate::default());
1571        assert!(result.is_err());
1572    }
1573
1574    #[test]
1575    fn test_delete_entity_cascades() {
1576        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1577        let a = hora.add_entity("project", "hora", None, None).unwrap();
1578        let b = hora.add_entity("language", "Rust", None, None).unwrap();
1579        let fact_id = hora.add_fact(a, b, "built_with", "desc", None).unwrap();
1580
1581        hora.delete_entity(a).unwrap();
1582
1583        // Entity gone
1584        assert!(hora.get_entity(a).unwrap().is_none());
1585        // Edge cascade-deleted
1586        assert!(hora.get_fact(fact_id).unwrap().is_none());
1587        // Other entity untouched
1588        assert!(hora.get_entity(b).unwrap().is_some());
1589        // b's edge list is clean
1590        assert_eq!(hora.get_entity_facts(b).unwrap().len(), 0);
1591    }
1592
1593    #[test]
1594    fn test_delete_entity_not_found() {
1595        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1596        let result = hora.delete_entity(EntityId(999));
1597        assert!(result.is_err());
1598    }
1599
1600    #[test]
1601    fn test_invalidate_fact() {
1602        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1603        let a = hora.add_entity("a", "x", None, None).unwrap();
1604        let b = hora.add_entity("b", "y", None, None).unwrap();
1605        let fact_id = hora.add_fact(a, b, "rel", "desc", None).unwrap();
1606
1607        hora.invalidate_fact(fact_id).unwrap();
1608
1609        let fact = hora.get_fact(fact_id).unwrap().unwrap();
1610        assert!(fact.invalid_at > 0);
1611        // Fact still exists, just marked as invalid
1612    }
1613
1614    #[test]
1615    fn test_invalidate_fact_twice_errors() {
1616        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1617        let a = hora.add_entity("a", "x", None, None).unwrap();
1618        let b = hora.add_entity("b", "y", None, None).unwrap();
1619        let fact_id = hora.add_fact(a, b, "rel", "desc", None).unwrap();
1620
1621        hora.invalidate_fact(fact_id).unwrap();
1622        let result = hora.invalidate_fact(fact_id);
1623        assert!(result.is_err());
1624    }
1625
1626    #[test]
1627    fn test_delete_fact() {
1628        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1629        let a = hora.add_entity("a", "x", None, None).unwrap();
1630        let b = hora.add_entity("b", "y", None, None).unwrap();
1631        let fact_id = hora.add_fact(a, b, "rel", "desc", None).unwrap();
1632
1633        hora.delete_fact(fact_id).unwrap();
1634        assert!(hora.get_fact(fact_id).unwrap().is_none());
1635        // Edge lists are clean
1636        assert_eq!(hora.get_entity_facts(a).unwrap().len(), 0);
1637        assert_eq!(hora.get_entity_facts(b).unwrap().len(), 0);
1638    }
1639
1640    #[test]
1641    fn test_delete_fact_not_found() {
1642        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1643        let result = hora.delete_fact(EdgeId(999));
1644        assert!(result.is_err());
1645    }
1646
1647    #[test]
1648    fn test_update_fact() {
1649        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1650        let a = hora.add_entity("a", "x", None, None).unwrap();
1651        let b = hora.add_entity("b", "y", None, None).unwrap();
1652        let fact_id = hora.add_fact(a, b, "rel", "desc", Some(0.5)).unwrap();
1653
1654        hora.update_fact(
1655            fact_id,
1656            FactUpdate {
1657                confidence: Some(0.95),
1658                ..Default::default()
1659            },
1660        )
1661        .unwrap();
1662
1663        let fact = hora.get_fact(fact_id).unwrap().unwrap();
1664        assert_eq!(fact.confidence, 0.95);
1665        assert_eq!(fact.description, "desc"); // unchanged
1666    }
1667
1668    #[test]
1669    fn test_props_macro() {
1670        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1671        let id = hora
1672            .add_entity(
1673                "project",
1674                "hora",
1675                Some(props! { "language" => "Rust", "stars" => 42 }),
1676                None,
1677            )
1678            .unwrap();
1679
1680        let entity = hora.get_entity(id).unwrap().unwrap();
1681        assert_eq!(
1682            entity.properties.get("language"),
1683            Some(&PropertyValue::String("Rust".into()))
1684        );
1685        assert_eq!(
1686            entity.properties.get("stars"),
1687            Some(&PropertyValue::Int(42))
1688        );
1689    }
1690
1691    #[test]
1692    fn test_stats_after_delete() {
1693        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1694        let a = hora.add_entity("a", "x", None, None).unwrap();
1695        let b = hora.add_entity("b", "y", None, None).unwrap();
1696        hora.add_fact(a, b, "rel", "desc", None).unwrap();
1697
1698        assert_eq!(hora.stats().unwrap().entities, 2);
1699        assert_eq!(hora.stats().unwrap().edges, 1);
1700
1701        hora.delete_entity(a).unwrap();
1702
1703        assert_eq!(hora.stats().unwrap().entities, 1);
1704        assert_eq!(hora.stats().unwrap().edges, 0); // cascade
1705    }
1706
1707    // --- v0.1c tests: Graph Traversal ---
1708
1709    #[test]
1710    fn test_bfs_depth_2() {
1711        // A -> B -> C -> D
1712        // traverse(A, depth=2) should return {A, B, C} but not D
1713        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1714        let a = hora.add_entity("node", "A", None, None).unwrap();
1715        let b = hora.add_entity("node", "B", None, None).unwrap();
1716        let c = hora.add_entity("node", "C", None, None).unwrap();
1717        let d = hora.add_entity("node", "D", None, None).unwrap();
1718
1719        hora.add_fact(a, b, "link", "A->B", None).unwrap();
1720        hora.add_fact(b, c, "link", "B->C", None).unwrap();
1721        hora.add_fact(c, d, "link", "C->D", None).unwrap();
1722
1723        let result = hora.traverse(a, TraverseOpts { depth: 2 }).unwrap();
1724
1725        assert!(result.entity_ids.contains(&a));
1726        assert!(result.entity_ids.contains(&b));
1727        assert!(result.entity_ids.contains(&c));
1728        assert!(!result.entity_ids.contains(&d));
1729        assert_eq!(result.entity_ids.len(), 3);
1730        assert_eq!(result.edge_ids.len(), 2); // A->B and B->C
1731    }
1732
1733    #[test]
1734    fn test_bfs_depth_0() {
1735        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1736        let a = hora.add_entity("node", "A", None, None).unwrap();
1737        let b = hora.add_entity("node", "B", None, None).unwrap();
1738        hora.add_fact(a, b, "link", "A->B", None).unwrap();
1739
1740        let result = hora.traverse(a, TraverseOpts { depth: 0 }).unwrap();
1741        assert_eq!(result.entity_ids.len(), 1);
1742        assert_eq!(result.entity_ids[0], a);
1743        assert_eq!(result.edge_ids.len(), 0);
1744    }
1745
1746    #[test]
1747    fn test_bfs_cycle() {
1748        // A -> B -> C -> A (cycle)
1749        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1750        let a = hora.add_entity("node", "A", None, None).unwrap();
1751        let b = hora.add_entity("node", "B", None, None).unwrap();
1752        let c = hora.add_entity("node", "C", None, None).unwrap();
1753
1754        hora.add_fact(a, b, "link", "A->B", None).unwrap();
1755        hora.add_fact(b, c, "link", "B->C", None).unwrap();
1756        hora.add_fact(c, a, "link", "C->A", None).unwrap();
1757
1758        // Should not infinite loop, and should find all 3 nodes
1759        let result = hora.traverse(a, TraverseOpts { depth: 10 }).unwrap();
1760        assert_eq!(result.entity_ids.len(), 3);
1761        assert_eq!(result.edge_ids.len(), 3);
1762    }
1763
1764    #[test]
1765    fn test_bfs_isolated_node() {
1766        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1767        let a = hora.add_entity("node", "lonely", None, None).unwrap();
1768
1769        let result = hora.traverse(a, TraverseOpts { depth: 5 }).unwrap();
1770        assert_eq!(result.entity_ids.len(), 1);
1771        assert_eq!(result.edge_ids.len(), 0);
1772    }
1773
1774    #[test]
1775    fn test_bfs_not_found() {
1776        let hora = HoraCore::new(HoraConfig::default()).unwrap();
1777        let result = hora.traverse(EntityId(999), TraverseOpts::default());
1778        assert!(result.is_err());
1779    }
1780
1781    #[test]
1782    fn test_neighbors() {
1783        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1784        let a = hora.add_entity("node", "A", None, None).unwrap();
1785        let b = hora.add_entity("node", "B", None, None).unwrap();
1786        let c = hora.add_entity("node", "C", None, None).unwrap();
1787        let d = hora.add_entity("node", "D", None, None).unwrap();
1788
1789        hora.add_fact(a, b, "link", "A->B", None).unwrap();
1790        hora.add_fact(a, c, "link", "A->C", None).unwrap();
1791        // D is not connected to A
1792
1793        let mut neighbors = hora.neighbors(a).unwrap();
1794        neighbors.sort();
1795        assert_eq!(neighbors.len(), 2);
1796        assert!(neighbors.contains(&b));
1797        assert!(neighbors.contains(&c));
1798        assert!(!neighbors.contains(&d));
1799    }
1800
1801    #[test]
1802    fn test_timeline_ordered() {
1803        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1804        let a = hora.add_entity("person", "Alice", None, None).unwrap();
1805        let b = hora.add_entity("company", "Acme", None, None).unwrap();
1806        let c = hora.add_entity("company", "BigCorp", None, None).unwrap();
1807
1808        // Create facts — since they're created sequentially, valid_at increases
1809        let f1 = hora
1810            .add_fact(a, b, "works_at", "Alice at Acme", None)
1811            .unwrap();
1812        let f2 = hora
1813            .add_fact(a, c, "works_at", "Alice at BigCorp", None)
1814            .unwrap();
1815
1816        let tl = hora.timeline(a).unwrap();
1817        assert_eq!(tl.len(), 2);
1818        assert_eq!(tl[0].id, f1);
1819        assert_eq!(tl[1].id, f2);
1820        assert!(tl[0].valid_at <= tl[1].valid_at);
1821    }
1822
1823    #[test]
1824    fn test_facts_at_bitemporal() {
1825        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1826        let a = hora.add_entity("a", "x", None, None).unwrap();
1827        let b = hora.add_entity("b", "y", None, None).unwrap();
1828
1829        // Manually craft edges with controlled timestamps via add_fact + update
1830        let f1 = hora.add_fact(a, b, "rel", "fact1", None).unwrap();
1831        let f2 = hora.add_fact(a, b, "rel2", "fact2", None).unwrap();
1832
1833        // Get the actual timestamps so we can reason about them
1834        let e1 = hora.get_fact(f1).unwrap().unwrap();
1835        let e2 = hora.get_fact(f2).unwrap().unwrap();
1836
1837        // Invalidate f1 — sets invalid_at to now
1838        hora.invalidate_fact(f1).unwrap();
1839        let e1_after = hora.get_fact(f1).unwrap().unwrap();
1840
1841        // facts_at(before everything) = nothing
1842        let before = hora.facts_at(e1.valid_at - 1).unwrap();
1843        assert_eq!(before.len(), 0);
1844
1845        // facts_at(between creation and invalidation) = both
1846        // Since f1 was valid from e1.valid_at until e1_after.invalid_at,
1847        // and f2 was valid from e2.valid_at with no end,
1848        // at time e2.valid_at both should be visible (before invalidation timestamp)
1849        let mid = hora.facts_at(e2.valid_at).unwrap();
1850        // f1 is still valid here (valid_at <= t, invalid_at > t because invalidation happens after)
1851        assert!(mid.iter().any(|e| e.id == f2));
1852
1853        // facts_at(well into the future) = only f2 (f1 is invalidated)
1854        let future = hora.facts_at(e1_after.invalid_at + 1000).unwrap();
1855        assert!(future.iter().any(|e| e.id == f2));
1856        assert!(!future.iter().any(|e| e.id == f1));
1857    }
1858
1859    #[test]
1860    fn test_facts_at_never_invalidated() {
1861        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
1862        let a = hora.add_entity("a", "x", None, None).unwrap();
1863        let b = hora.add_entity("b", "y", None, None).unwrap();
1864        let f = hora.add_fact(a, b, "rel", "always valid", None).unwrap();
1865
1866        let edge = hora.get_fact(f).unwrap().unwrap();
1867
1868        // A fact with invalid_at=0 is valid at any time >= valid_at
1869        let result = hora.facts_at(edge.valid_at + 1_000_000).unwrap();
1870        assert_eq!(result.len(), 1);
1871        assert_eq!(result[0].id, f);
1872    }
1873
1874    // --- v0.1d tests: Persistence ---
1875
1876    #[test]
1877    fn test_persistence_roundtrip() {
1878        let dir = tempfile::tempdir().unwrap();
1879        let path = dir.path().join("test.hora");
1880
1881        let (a_id, b_id, fact_id);
1882        {
1883            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1884            a_id = hora.add_entity("project", "hora", None, None).unwrap();
1885            b_id = hora
1886                .add_entity("language", "Rust", Some(props! { "year" => 2015 }), None)
1887                .unwrap();
1888            fact_id = hora
1889                .add_fact(a_id, b_id, "built_with", "hora uses Rust", Some(0.95))
1890                .unwrap();
1891            hora.flush().unwrap();
1892        }
1893
1894        // Reopen and verify
1895        {
1896            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1897            let stats = hora.stats().unwrap();
1898            assert_eq!(stats.entities, 2);
1899            assert_eq!(stats.edges, 1);
1900
1901            let a = hora.get_entity(a_id).unwrap().unwrap();
1902            assert_eq!(a.name, "hora");
1903            assert_eq!(a.entity_type, "project");
1904
1905            let b = hora.get_entity(b_id).unwrap().unwrap();
1906            assert_eq!(b.name, "Rust");
1907            assert_eq!(b.properties.get("year"), Some(&PropertyValue::Int(2015)));
1908
1909            let fact = hora.get_fact(fact_id).unwrap().unwrap();
1910            assert_eq!(fact.relation_type, "built_with");
1911            assert_eq!(fact.confidence, 0.95);
1912        }
1913    }
1914
1915    #[test]
1916    fn test_persistence_ids_continue() {
1917        let dir = tempfile::tempdir().unwrap();
1918        let path = dir.path().join("test.hora");
1919
1920        {
1921            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1922            hora.add_entity("a", "first", None, None).unwrap(); // id=1
1923            hora.add_entity("b", "second", None, None).unwrap(); // id=2
1924            hora.flush().unwrap();
1925        }
1926
1927        {
1928            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1929            let id = hora.add_entity("c", "third", None, None).unwrap();
1930            // ID should continue from where we left off (3), not restart at 1
1931            assert_eq!(id.0, 3);
1932        }
1933    }
1934
1935    #[test]
1936    fn test_persistence_with_embeddings() {
1937        let config = HoraConfig {
1938            embedding_dims: 3,
1939            dedup: DedupConfig::disabled(),
1940        };
1941        let dir = tempfile::tempdir().unwrap();
1942        let path = dir.path().join("test.hora");
1943
1944        {
1945            let mut hora = HoraCore::open(&path, config.clone()).unwrap();
1946            let emb = vec![1.0, 2.0, 3.0];
1947            hora.add_entity("a", "x", None, Some(&emb)).unwrap();
1948            hora.flush().unwrap();
1949        }
1950
1951        {
1952            let mut hora = HoraCore::open(&path, config).unwrap();
1953            let e = hora.get_entity(EntityId(1)).unwrap().unwrap();
1954            assert_eq!(e.embedding.as_ref().unwrap(), &[1.0, 2.0, 3.0]);
1955        }
1956    }
1957
1958    #[test]
1959    fn test_persistence_with_episodes() {
1960        let dir = tempfile::tempdir().unwrap();
1961        let path = dir.path().join("test.hora");
1962
1963        {
1964            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1965            let a = hora.add_entity("a", "x", None, None).unwrap();
1966            hora.add_episode(EpisodeSource::Conversation, "sess-1", &[a], &[])
1967                .unwrap();
1968            hora.flush().unwrap();
1969        }
1970
1971        {
1972            let hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1973            let stats = hora.stats().unwrap();
1974            assert_eq!(stats.episodes, 1);
1975        }
1976    }
1977
1978    #[test]
1979    fn test_persistence_invalidated_fact() {
1980        let dir = tempfile::tempdir().unwrap();
1981        let path = dir.path().join("test.hora");
1982
1983        let fact_id;
1984        {
1985            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1986            let a = hora.add_entity("a", "x", None, None).unwrap();
1987            let b = hora.add_entity("b", "y", None, None).unwrap();
1988            fact_id = hora.add_fact(a, b, "rel", "desc", None).unwrap();
1989            hora.invalidate_fact(fact_id).unwrap();
1990            hora.flush().unwrap();
1991        }
1992
1993        {
1994            let hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
1995            let fact = hora.get_fact(fact_id).unwrap().unwrap();
1996            assert!(fact.invalid_at > 0);
1997        }
1998    }
1999
2000    #[test]
2001    fn test_corrupted_file_detected() {
2002        let dir = tempfile::tempdir().unwrap();
2003        let path = dir.path().join("bad.hora");
2004        std::fs::write(&path, b"NOT_HORA_FILE").unwrap();
2005
2006        let result = HoraCore::open(&path, HoraConfig::default());
2007        assert!(result.is_err());
2008    }
2009
2010    #[test]
2011    fn test_snapshot() {
2012        let dir = tempfile::tempdir().unwrap();
2013        let path = dir.path().join("test.hora");
2014        let snap = dir.path().join("snapshot.hora");
2015
2016        {
2017            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
2018            hora.add_entity("project", "hora", None, None).unwrap();
2019            hora.flush().unwrap();
2020            hora.snapshot(&snap).unwrap();
2021        }
2022
2023        // Open from snapshot
2024        {
2025            let hora = HoraCore::open(&snap, HoraConfig::default()).unwrap();
2026            assert_eq!(hora.stats().unwrap().entities, 1);
2027        }
2028    }
2029
2030    #[test]
2031    fn test_flush_memory_only_errors() {
2032        let hora = HoraCore::new(HoraConfig::default()).unwrap();
2033        let result = hora.flush();
2034        assert!(result.is_err());
2035    }
2036
2037    #[test]
2038    fn test_snapshot_memory_instance() {
2039        let dir = tempfile::tempdir().unwrap();
2040        let snap = dir.path().join("snapshot.hora");
2041
2042        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2043        hora.add_entity("project", "hora", None, None).unwrap();
2044        hora.snapshot(&snap).unwrap();
2045
2046        let hora2 = HoraCore::open(&snap, HoraConfig::default()).unwrap();
2047        assert_eq!(hora2.stats().unwrap().entities, 1);
2048    }
2049
2050    #[test]
2051    fn test_persistence_all_property_types() {
2052        let dir = tempfile::tempdir().unwrap();
2053        let path = dir.path().join("test.hora");
2054
2055        {
2056            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
2057            hora.add_entity(
2058                "test",
2059                "props",
2060                Some(props! {
2061                    "name" => "hora",
2062                    "stars" => 42,
2063                    "score" => 2.72,
2064                    "active" => true
2065                }),
2066                None,
2067            )
2068            .unwrap();
2069            hora.flush().unwrap();
2070        }
2071
2072        {
2073            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
2074            let e = hora.get_entity(EntityId(1)).unwrap().unwrap();
2075            assert_eq!(
2076                e.properties.get("name"),
2077                Some(&PropertyValue::String("hora".into()))
2078            );
2079            assert_eq!(e.properties.get("stars"), Some(&PropertyValue::Int(42)));
2080            assert_eq!(e.properties.get("score"), Some(&PropertyValue::Float(2.72)));
2081            assert_eq!(e.properties.get("active"), Some(&PropertyValue::Bool(true)));
2082        }
2083    }
2084
2085    // --- v0.2a tests: Vector Search ---
2086
2087    #[test]
2088    fn test_vector_search_basic() {
2089        let config = HoraConfig {
2090            embedding_dims: 3,
2091            dedup: DedupConfig::disabled(),
2092        };
2093        let mut hora = HoraCore::new(config).unwrap();
2094
2095        // Entity close to query
2096        hora.add_entity("a", "close", None, Some(&[1.0, 0.0, 0.0]))
2097            .unwrap();
2098        // Entity far from query
2099        hora.add_entity("b", "far", None, Some(&[0.0, 1.0, 0.0]))
2100            .unwrap();
2101        // Entity very close to query
2102        hora.add_entity("c", "very_close", None, Some(&[0.9, 0.1, 0.0]))
2103            .unwrap();
2104
2105        let results = hora.vector_search(&[1.0, 0.0, 0.0], 2).unwrap();
2106
2107        assert_eq!(results.len(), 2);
2108        // First result should be the exact match
2109        assert_eq!(results[0].entity_id, EntityId(1));
2110        // Second should be the close one
2111        assert_eq!(results[1].entity_id, EntityId(3));
2112    }
2113
2114    #[test]
2115    fn test_vector_search_returns_exact_k() {
2116        let config = HoraConfig {
2117            embedding_dims: 3,
2118            dedup: DedupConfig::disabled(),
2119        };
2120        let mut hora = HoraCore::new(config).unwrap();
2121
2122        for i in 0..20 {
2123            let emb = vec![i as f32, 0.0, 1.0];
2124            hora.add_entity("node", &format!("n{}", i), None, Some(&emb))
2125                .unwrap();
2126        }
2127
2128        let results = hora.vector_search(&[10.0, 0.0, 1.0], 5).unwrap();
2129        assert_eq!(results.len(), 5);
2130    }
2131
2132    #[test]
2133    fn test_vector_search_skips_no_embedding() {
2134        let config = HoraConfig {
2135            embedding_dims: 3,
2136            dedup: DedupConfig::disabled(),
2137        };
2138        let mut hora = HoraCore::new(config).unwrap();
2139
2140        // One with embedding, one without
2141        hora.add_entity("a", "with_emb", None, Some(&[1.0, 0.0, 0.0]))
2142            .unwrap();
2143        hora.add_entity("b", "no_emb", None, None).unwrap();
2144
2145        let results = hora.vector_search(&[1.0, 0.0, 0.0], 10).unwrap();
2146        assert_eq!(results.len(), 1);
2147    }
2148
2149    #[test]
2150    fn test_vector_search_dims_mismatch() {
2151        let config = HoraConfig {
2152            embedding_dims: 3,
2153            dedup: DedupConfig::disabled(),
2154        };
2155        let hora = HoraCore::new(config).unwrap();
2156
2157        // Query with wrong dimensions
2158        let result = hora.vector_search(&[1.0, 0.0], 10);
2159        assert!(result.is_err());
2160    }
2161
2162    #[test]
2163    fn test_vector_search_dims_zero_errors() {
2164        let hora = HoraCore::new(HoraConfig::default()).unwrap();
2165        let result = hora.vector_search(&[1.0, 0.0, 0.0], 10);
2166        assert!(result.is_err());
2167    }
2168
2169    #[test]
2170    fn test_vector_search_empty_graph() {
2171        let config = HoraConfig {
2172            embedding_dims: 3,
2173            dedup: DedupConfig::disabled(),
2174        };
2175        let hora = HoraCore::new(config).unwrap();
2176
2177        let results = hora.vector_search(&[1.0, 0.0, 0.0], 10).unwrap();
2178        assert_eq!(results.len(), 0);
2179    }
2180
2181    #[test]
2182    fn test_vector_search_k_larger_than_corpus() {
2183        let config = HoraConfig {
2184            embedding_dims: 3,
2185            dedup: DedupConfig::disabled(),
2186        };
2187        let mut hora = HoraCore::new(config).unwrap();
2188
2189        hora.add_entity("a", "x", None, Some(&[1.0, 0.0, 0.0]))
2190            .unwrap();
2191
2192        let results = hora.vector_search(&[1.0, 0.0, 0.0], 100).unwrap();
2193        assert_eq!(results.len(), 1);
2194    }
2195
2196    #[test]
2197    fn test_vector_search_scores_descending() {
2198        let config = HoraConfig {
2199            embedding_dims: 3,
2200            dedup: DedupConfig::disabled(),
2201        };
2202        let mut hora = HoraCore::new(config).unwrap();
2203
2204        hora.add_entity("a", "x", None, Some(&[1.0, 0.0, 0.0]))
2205            .unwrap();
2206        hora.add_entity("b", "y", None, Some(&[0.5, 0.5, 0.0]))
2207            .unwrap();
2208        hora.add_entity("c", "z", None, Some(&[0.0, 1.0, 0.0]))
2209            .unwrap();
2210
2211        let results = hora.vector_search(&[1.0, 0.0, 0.0], 3).unwrap();
2212        for w in results.windows(2) {
2213            assert!(w[0].score >= w[1].score);
2214        }
2215    }
2216
2217    // --- v0.2b tests: BM25 Text Search ---
2218
2219    #[test]
2220    fn test_text_search_finds_by_name() {
2221        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2222        hora.add_entity("project", "hora graph engine", None, None)
2223            .unwrap();
2224        hora.add_entity("language", "Rust programming", None, None)
2225            .unwrap();
2226
2227        let results = hora.text_search("hora", 10).unwrap();
2228        assert_eq!(results.len(), 1);
2229        assert_eq!(results[0].entity_id, EntityId(1));
2230    }
2231
2232    #[test]
2233    fn test_text_search_finds_by_properties() {
2234        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2235        hora.add_entity(
2236            "project",
2237            "hora",
2238            Some(props! { "description" => "knowledge graph authentication engine" }),
2239            None,
2240        )
2241        .unwrap();
2242        hora.add_entity("other", "unrelated", None, None).unwrap();
2243
2244        let results = hora.text_search("authentication", 10).unwrap();
2245        assert_eq!(results.len(), 1);
2246        assert_eq!(results[0].entity_id, EntityId(1));
2247    }
2248
2249    #[test]
2250    fn test_text_search_tf_ranking() {
2251        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2252        // Same doc length, but entity 1 has "rust" 3 times vs entity 2's 1 time
2253        hora.add_entity("a", "rust rust rust", None, None).unwrap();
2254        hora.add_entity("b", "rust java python", None, None)
2255            .unwrap();
2256
2257        let results = hora.text_search("rust", 10).unwrap();
2258        assert_eq!(results.len(), 2);
2259        // More occurrences (same length) → higher score
2260        assert_eq!(results[0].entity_id, EntityId(1));
2261        assert!(results[0].score > results[1].score);
2262    }
2263
2264    #[test]
2265    fn test_text_search_no_match() {
2266        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2267        hora.add_entity("project", "hora", None, None).unwrap();
2268
2269        let results = hora.text_search("nonexistent", 10).unwrap();
2270        assert!(results.is_empty());
2271    }
2272
2273    #[test]
2274    fn test_text_search_respects_delete() {
2275        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2276        let id = hora
2277            .add_entity("project", "hora graph engine", None, None)
2278            .unwrap();
2279
2280        hora.delete_entity(id).unwrap();
2281
2282        let results = hora.text_search("hora", 10).unwrap();
2283        assert!(results.is_empty());
2284    }
2285
2286    #[test]
2287    fn test_text_search_respects_update() {
2288        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2289        let id = hora
2290            .add_entity("project", "old name cats", None, None)
2291            .unwrap();
2292
2293        hora.update_entity(
2294            id,
2295            EntityUpdate {
2296                name: Some("new name dogs".to_string()),
2297                ..Default::default()
2298            },
2299        )
2300        .unwrap();
2301
2302        assert!(hora.text_search("cats", 10).unwrap().is_empty());
2303        assert_eq!(hora.text_search("dogs", 10).unwrap().len(), 1);
2304    }
2305
2306    #[test]
2307    fn test_text_search_after_persistence_roundtrip() {
2308        let dir = tempfile::tempdir().unwrap();
2309        let path = dir.path().join("bm25.hora");
2310
2311        {
2312            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
2313            hora.add_entity("project", "hora graph engine", None, None)
2314                .unwrap();
2315            hora.add_entity("language", "rust programming", None, None)
2316                .unwrap();
2317            hora.flush().unwrap();
2318        }
2319
2320        // Reopen → BM25 index rebuilt from entities
2321        {
2322            let mut hora = HoraCore::open(&path, HoraConfig::default()).unwrap();
2323            let results = hora.text_search("hora", 10).unwrap();
2324            assert_eq!(results.len(), 1);
2325
2326            let results = hora.text_search("rust", 10).unwrap();
2327            assert_eq!(results.len(), 1);
2328        }
2329    }
2330
2331    // --- v0.2c tests: Hybrid Search (RRF) ---
2332
2333    #[test]
2334    fn test_hybrid_search_both_legs() {
2335        let config = HoraConfig {
2336            embedding_dims: 3,
2337            dedup: DedupConfig::disabled(),
2338        };
2339        let mut hora = HoraCore::new(config).unwrap();
2340
2341        // Entity 1: strong vector match + text match → should rank highest
2342        hora.add_entity("a", "rust language", None, Some(&[1.0, 0.0, 0.0]))
2343            .unwrap();
2344        // Entity 2: text match only
2345        hora.add_entity("b", "rust compiler", None, Some(&[0.0, 1.0, 0.0]))
2346            .unwrap();
2347        // Entity 3: vector match only (no "rust" in name)
2348        hora.add_entity("c", "speed daemon", None, Some(&[0.9, 0.1, 0.0]))
2349            .unwrap();
2350
2351        let results = hora
2352            .search(
2353                Some("rust"),
2354                Some(&[1.0, 0.0, 0.0]),
2355                SearchOpts {
2356                    top_k: 10,
2357                    ..Default::default()
2358                },
2359            )
2360            .unwrap();
2361
2362        // Entity 1 found by both legs → should be first
2363        assert_eq!(results[0].entity_id, EntityId(1));
2364        assert!(results.len() >= 2);
2365        // Scores descending
2366        for w in results.windows(2) {
2367            assert!(w[0].score >= w[1].score);
2368        }
2369    }
2370
2371    #[test]
2372    fn test_hybrid_search_text_only_mode() {
2373        // embedding_dims=0 → vector leg skipped, pure BM25
2374        let config = HoraConfig {
2375            embedding_dims: 0,
2376            dedup: DedupConfig::disabled(),
2377        };
2378        let mut hora = HoraCore::new(config).unwrap();
2379
2380        hora.add_entity("a", "rust language", None, None).unwrap();
2381        hora.add_entity("b", "python language", None, None).unwrap();
2382
2383        let results = hora
2384            .search(Some("rust"), None, SearchOpts::default())
2385            .unwrap();
2386
2387        assert_eq!(results.len(), 1);
2388        assert_eq!(results[0].entity_id, EntityId(1));
2389    }
2390
2391    #[test]
2392    fn test_hybrid_search_vector_only_mode() {
2393        let config = HoraConfig {
2394            embedding_dims: 3,
2395            dedup: DedupConfig::disabled(),
2396        };
2397        let mut hora = HoraCore::new(config).unwrap();
2398
2399        hora.add_entity("a", "alpha", None, Some(&[1.0, 0.0, 0.0]))
2400            .unwrap();
2401        hora.add_entity("b", "beta", None, Some(&[0.0, 1.0, 0.0]))
2402            .unwrap();
2403
2404        // No text query → vector leg only
2405        let results = hora
2406            .search(None, Some(&[1.0, 0.0, 0.0]), SearchOpts::default())
2407            .unwrap();
2408
2409        assert_eq!(results[0].entity_id, EntityId(1));
2410        assert!(results[0].score > results[1].score);
2411    }
2412
2413    #[test]
2414    fn test_hybrid_search_neither_leg() {
2415        let config = HoraConfig {
2416            embedding_dims: 3,
2417            dedup: DedupConfig::disabled(),
2418        };
2419        let mut hora = HoraCore::new(config).unwrap();
2420        hora.add_entity("a", "test", None, Some(&[1.0, 0.0, 0.0]))
2421            .unwrap();
2422
2423        let results = hora.search(None, None, SearchOpts::default()).unwrap();
2424
2425        assert!(results.is_empty());
2426    }
2427
2428    #[test]
2429    fn test_hybrid_search_top_k_respected() {
2430        let config = HoraConfig {
2431            embedding_dims: 3,
2432            dedup: DedupConfig::disabled(),
2433        };
2434        let mut hora = HoraCore::new(config).unwrap();
2435
2436        for i in 0..20 {
2437            let emb = [1.0 - i as f32 * 0.01, 0.0, 0.0];
2438            hora.add_entity("t", &format!("entity{i}"), None, Some(&emb))
2439                .unwrap();
2440        }
2441
2442        let results = hora
2443            .search(
2444                None,
2445                Some(&[1.0, 0.0, 0.0]),
2446                SearchOpts {
2447                    top_k: 5,
2448                    ..Default::default()
2449                },
2450            )
2451            .unwrap();
2452
2453        assert_eq!(results.len(), 5);
2454    }
2455
2456    #[test]
2457    fn test_hybrid_search_wrong_dims_skips_vector() {
2458        let config = HoraConfig {
2459            embedding_dims: 3,
2460            dedup: DedupConfig::disabled(),
2461        };
2462        let mut hora = HoraCore::new(config).unwrap();
2463
2464        hora.add_entity("a", "rust language", None, Some(&[1.0, 0.0, 0.0]))
2465            .unwrap();
2466
2467        // Wrong embedding dims → vector leg skipped, BM25 only
2468        let results = hora
2469            .search(Some("rust"), Some(&[1.0, 0.0]), SearchOpts::default())
2470            .unwrap();
2471
2472        assert_eq!(results.len(), 1);
2473        assert_eq!(results[0].entity_id, EntityId(1));
2474    }
2475
2476    // --- v0.2d tests: Deduplication ---
2477
2478    #[test]
2479    fn test_dedup_name_exact_normalization() {
2480        // "hora-engine" and "Hora Engine" should be detected as duplicates
2481        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2482
2483        let id1 = hora
2484            .add_entity("project", "Hora Engine", None, None)
2485            .unwrap();
2486        let id2 = hora
2487            .add_entity("project", "hora-engine", None, None)
2488            .unwrap();
2489
2490        // Should return the existing entity's ID
2491        assert_eq!(id1, id2);
2492        // Only 1 entity should exist
2493        assert_eq!(hora.stats().unwrap().entities, 1);
2494    }
2495
2496    #[test]
2497    fn test_dedup_name_case_insensitive() {
2498        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2499
2500        let id1 = hora.add_entity("project", "Rust", None, None).unwrap();
2501        let id2 = hora.add_entity("project", "rust", None, None).unwrap();
2502        let id3 = hora.add_entity("project", "RUST", None, None).unwrap();
2503
2504        assert_eq!(id1, id2);
2505        assert_eq!(id1, id3);
2506        assert_eq!(hora.stats().unwrap().entities, 1);
2507    }
2508
2509    #[test]
2510    fn test_dedup_different_type_allows_same_name() {
2511        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2512
2513        let id1 = hora.add_entity("project", "rust", None, None).unwrap();
2514        let id2 = hora.add_entity("language", "rust", None, None).unwrap();
2515
2516        // Different types → not a duplicate
2517        assert_ne!(id1, id2);
2518        assert_eq!(hora.stats().unwrap().entities, 2);
2519    }
2520
2521    #[test]
2522    fn test_dedup_cosine_embedding() {
2523        let config = HoraConfig {
2524            embedding_dims: 3,
2525            ..Default::default()
2526        };
2527        let mut hora = HoraCore::new(config).unwrap();
2528
2529        let emb1 = [1.0, 0.0, 0.0];
2530        let emb2 = [0.99, 0.1, 0.0]; // very similar (cosine > 0.99)
2531
2532        let id1 = hora
2533            .add_entity("concept", "alpha", None, Some(&emb1))
2534            .unwrap();
2535        let id2 = hora
2536            .add_entity("concept", "beta", None, Some(&emb2))
2537            .unwrap();
2538
2539        // Different names but similar embeddings → duplicate
2540        assert_eq!(id1, id2);
2541        assert_eq!(hora.stats().unwrap().entities, 1);
2542    }
2543
2544    #[test]
2545    fn test_dedup_cosine_below_threshold() {
2546        let config = HoraConfig {
2547            embedding_dims: 3,
2548            ..Default::default()
2549        };
2550        let mut hora = HoraCore::new(config).unwrap();
2551
2552        let emb1 = [1.0, 0.0, 0.0];
2553        let emb2 = [0.0, 1.0, 0.0]; // orthogonal → cosine = 0
2554
2555        let id1 = hora
2556            .add_entity("concept", "alpha", None, Some(&emb1))
2557            .unwrap();
2558        let id2 = hora
2559            .add_entity("concept", "beta", None, Some(&emb2))
2560            .unwrap();
2561
2562        // Very different embeddings → not a duplicate
2563        assert_ne!(id1, id2);
2564        assert_eq!(hora.stats().unwrap().entities, 2);
2565    }
2566
2567    #[test]
2568    fn test_dedup_disabled() {
2569        let config = HoraConfig {
2570            dedup: DedupConfig::disabled(),
2571            ..Default::default()
2572        };
2573        let mut hora = HoraCore::new(config).unwrap();
2574
2575        let id1 = hora.add_entity("project", "rust", None, None).unwrap();
2576        let id2 = hora.add_entity("project", "rust", None, None).unwrap();
2577
2578        // Dedup disabled → both created
2579        assert_ne!(id1, id2);
2580        assert_eq!(hora.stats().unwrap().entities, 2);
2581    }
2582
2583    #[test]
2584    fn test_dedup_no_id_increment_on_duplicate() {
2585        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2586
2587        let id1 = hora.add_entity("project", "hora", None, None).unwrap();
2588        let _id2 = hora.add_entity("project", "hora", None, None).unwrap(); // dedup → returns id1
2589
2590        // Next unique entity should get id=2, not id=3
2591        let id3 = hora.add_entity("language", "rust", None, None).unwrap();
2592        assert_eq!(id1, EntityId(1));
2593        assert_eq!(id3, EntityId(2));
2594    }
2595
2596    #[test]
2597    fn test_dedup_configurable_thresholds() {
2598        // Lower Jaccard threshold → easier to dedup
2599        let config = HoraConfig {
2600            dedup: DedupConfig {
2601                enabled: true,
2602                name_exact: false, // disable exact name to test Jaccard only
2603                jaccard_threshold: 0.5,
2604                cosine_threshold: 0.0,
2605            },
2606            ..Default::default()
2607        };
2608        let mut hora = HoraCore::new(config).unwrap();
2609
2610        // "rust graph engine" tokens: [rust, graph, engine]
2611        let id1 = hora
2612            .add_entity("project", "rust graph engine", None, None)
2613            .unwrap();
2614        // "rust graph database" tokens: [rust, graph, database] → Jaccard 2/4 = 0.5 >= 0.5
2615        let id2 = hora
2616            .add_entity("project", "rust graph database", None, None)
2617            .unwrap();
2618
2619        assert_eq!(id1, id2);
2620    }
2621
2622    // --- v0.3a tests: ACT-R Activation ---
2623
2624    #[test]
2625    fn test_activation_exists_after_creation() {
2626        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2627        let id = hora.add_entity("a", "test", None, None).unwrap();
2628
2629        let act = hora.get_activation(id);
2630        assert!(act.is_some());
2631        // Activation should be finite (1 access at creation)
2632        assert!(act.unwrap().is_finite());
2633    }
2634
2635    #[test]
2636    fn test_activation_increases_with_access() {
2637        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2638        let id = hora.add_entity("a", "test", None, None).unwrap();
2639
2640        let act_before = hora.get_activation(id).unwrap();
2641        // get_entity records an access
2642        let _ = hora.get_entity(id).unwrap();
2643        let act_after = hora.get_activation(id).unwrap();
2644
2645        // More accesses → higher activation
2646        assert!(
2647            act_after > act_before,
2648            "act_after={act_after} should be > act_before={act_before}"
2649        );
2650    }
2651
2652    #[test]
2653    fn test_activation_none_for_unknown_entity() {
2654        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2655        assert!(hora.get_activation(EntityId(999)).is_none());
2656    }
2657
2658    #[test]
2659    fn test_activation_removed_on_delete() {
2660        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2661        let id = hora.add_entity("a", "test", None, None).unwrap();
2662
2663        assert!(hora.get_activation(id).is_some());
2664        hora.delete_entity(id).unwrap();
2665        assert!(hora.get_activation(id).is_none());
2666    }
2667
2668    #[test]
2669    fn test_record_access_manually() {
2670        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2671        let id = hora.add_entity("a", "test", None, None).unwrap();
2672
2673        let act_before = hora.get_activation(id).unwrap();
2674        hora.record_access(id);
2675        hora.record_access(id);
2676        hora.record_access(id);
2677        let act_after = hora.get_activation(id).unwrap();
2678
2679        assert!(act_after > act_before);
2680    }
2681
2682    #[test]
2683    fn test_search_records_access_side_effect() {
2684        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2685        let id = hora.add_entity("a", "rust language", None, None).unwrap();
2686
2687        let act_before = hora.get_activation(id).unwrap();
2688
2689        // text_search doesn't record access, but search() does
2690        hora.search(Some("rust"), None, SearchOpts::default())
2691            .unwrap();
2692
2693        let act_after = hora.get_activation(id).unwrap();
2694        assert!(
2695            act_after > act_before,
2696            "search should increase activation: before={act_before}, after={act_after}"
2697        );
2698    }
2699
2700    // ── Spreading Activation (v0.3b) ────────────────────────
2701
2702    #[test]
2703    fn test_spread_activation_simple() {
2704        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2705        let a = hora.add_entity("node", "A", None, None).unwrap();
2706        let b = hora.add_entity("node", "B", None, None).unwrap();
2707        hora.add_fact(a, b, "link", "A-B", None).unwrap();
2708
2709        let params = SpreadingParams::default();
2710        let result = hora.spread_activation(&[(a, 1.0)], &params).unwrap();
2711
2712        // B should receive positive activation (fan=1, s_ji = 1.6 - ln(1) = 1.6 > 0)
2713        assert!(result.contains_key(&b));
2714        assert!(result[&b] > 0.0, "B should have positive activation");
2715    }
2716
2717    #[test]
2718    fn test_spread_activation_fan_inhibition() {
2719        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2720        let hub = hora.add_entity("node", "hub", None, None).unwrap();
2721        // Connect hub to 10 nodes → fan=10, s_ji = 1.6 - ln(10) ≈ -0.70
2722        let mut leaves = Vec::new();
2723        for i in 0..10 {
2724            let leaf = hora
2725                .add_entity("node", &format!("leaf{i}"), None, None)
2726                .unwrap();
2727            hora.add_fact(hub, leaf, "link", &format!("hub-leaf{i}"), None)
2728                .unwrap();
2729            leaves.push(leaf);
2730        }
2731
2732        let params = SpreadingParams::default();
2733        let result = hora.spread_activation(&[(hub, 1.0)], &params).unwrap();
2734
2735        // Fan=10 → negative spreading (inhibition)
2736        for leaf in &leaves {
2737            let act = result[leaf];
2738            assert!(
2739                act < 0.0,
2740                "Leaf should have negative activation (inhibition), got {act}"
2741            );
2742        }
2743    }
2744
2745    #[test]
2746    fn test_spread_activation_depth_limit() {
2747        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2748        let a = hora.add_entity("node", "A", None, None).unwrap();
2749        let b = hora.add_entity("node", "B", None, None).unwrap();
2750        let c = hora.add_entity("node", "C", None, None).unwrap();
2751        let d = hora.add_entity("node", "D", None, None).unwrap();
2752        hora.add_fact(a, b, "link", "A-B", None).unwrap();
2753        hora.add_fact(b, c, "link", "B-C", None).unwrap();
2754        hora.add_fact(c, d, "link", "C-D", None).unwrap();
2755
2756        let params = SpreadingParams {
2757            max_depth: 2,
2758            ..Default::default()
2759        };
2760        let result = hora.spread_activation(&[(a, 1.0)], &params).unwrap();
2761
2762        // A, B, C should have activation; D should not (beyond depth 2)
2763        assert!(result.contains_key(&a));
2764        assert!(result.contains_key(&b));
2765        assert!(result.contains_key(&c));
2766        let d_act = result.get(&d).copied().unwrap_or(0.0);
2767        assert!(
2768            d_act.abs() < f64::EPSILON,
2769            "D should have no activation at depth 2, got {d_act}"
2770        );
2771    }
2772
2773    #[test]
2774    fn test_spread_activation_multiple_sources() {
2775        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2776        let a = hora.add_entity("node", "A", None, None).unwrap();
2777        let b = hora.add_entity("node", "B", None, None).unwrap();
2778        let c = hora.add_entity("node", "C", None, None).unwrap();
2779        hora.add_fact(a, c, "link", "A-C", None).unwrap();
2780        hora.add_fact(b, c, "link", "B-C", None).unwrap();
2781
2782        let params = SpreadingParams::default();
2783        let result = hora
2784            .spread_activation(&[(a, 1.0), (b, 1.0)], &params)
2785            .unwrap();
2786
2787        // C receives activation from both A and B
2788        let c_act = result[&c];
2789        assert!(
2790            c_act > 0.0,
2791            "C should have positive activation from 2 sources, got {c_act}"
2792        );
2793    }
2794
2795    #[test]
2796    fn test_spread_activation_no_edges() {
2797        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2798        let a = hora.add_entity("node", "isolated", None, None).unwrap();
2799
2800        let params = SpreadingParams::default();
2801        let result = hora.spread_activation(&[(a, 1.0)], &params).unwrap();
2802
2803        // Only source present, no propagation
2804        assert_eq!(result.len(), 1);
2805        assert!((result[&a] - 1.0).abs() < f64::EPSILON);
2806    }
2807
2808    #[test]
2809    fn test_spread_activation_cycle_terminates() {
2810        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2811        let a = hora.add_entity("node", "A", None, None).unwrap();
2812        let b = hora.add_entity("node", "B", None, None).unwrap();
2813        hora.add_fact(a, b, "link", "A-B", None).unwrap();
2814
2815        let params = SpreadingParams::default();
2816        // Should terminate without hanging (cycle via bidirectional edges)
2817        let result = hora.spread_activation(&[(a, 1.0)], &params).unwrap();
2818        assert!(result.contains_key(&a));
2819        assert!(result.contains_key(&b));
2820    }
2821
2822    // ── Reconsolidation (v0.3c) ─────────────────────────────
2823
2824    #[test]
2825    fn test_reconsolidation_initial_state_stable() {
2826        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2827        let id = hora.add_entity("node", "A", None, None).unwrap();
2828
2829        let phase = hora.get_memory_phase(id).unwrap().clone();
2830        assert_eq!(phase, MemoryPhase::Stable);
2831    }
2832
2833    #[test]
2834    fn test_reconsolidation_removed_on_delete() {
2835        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2836        let id = hora.add_entity("node", "A", None, None).unwrap();
2837        hora.delete_entity(id).unwrap();
2838        assert!(hora.get_memory_phase(id).is_none());
2839    }
2840
2841    #[test]
2842    fn test_reconsolidation_stability_multiplier_default() {
2843        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2844        let id = hora.add_entity("node", "A", None, None).unwrap();
2845
2846        let mult = hora.get_stability_multiplier(id).unwrap();
2847        assert!((mult - 1.0).abs() < f64::EPSILON);
2848    }
2849
2850    #[test]
2851    fn test_reconsolidation_strong_access_destabilizes() {
2852        // We need to control timing precisely, so we test the state module directly
2853        // but via HoraCore's reconsolidation_states for integration.
2854        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2855        let id = hora.add_entity("node", "A", None, None).unwrap();
2856
2857        // Access many times to build up activation above threshold (0.5)
2858        // Each get_entity call records access, which triggers reconsolidation check
2859        for _ in 0..5 {
2860            let _ = hora.get_entity(id);
2861        }
2862
2863        // After enough accesses, activation should be high enough to destabilize
2864        let activation = hora.get_activation(id).unwrap();
2865
2866        // The reconsolidation check happens inside record_access.
2867        // If activation > 0.5, entity should be Labile.
2868        if activation >= 0.5 {
2869            let phase = hora.get_memory_phase(id).unwrap().clone();
2870            assert!(
2871                matches!(phase, MemoryPhase::Labile { .. }),
2872                "Expected Labile for activation {activation}, got {phase:?}"
2873            );
2874        }
2875    }
2876
2877    #[test]
2878    fn test_reconsolidation_unit_level_full_cycle() {
2879        // Direct unit test of the reconsolidation state within HoraCore
2880        use crate::memory::reconsolidation::{ReconsolidationParams, ReconsolidationState};
2881
2882        let params = ReconsolidationParams {
2883            labile_window_secs: 100.0,
2884            restabilization_secs: 200.0,
2885            destabilization_threshold: 0.0, // always destabilize
2886            restabilization_boost: 1.5,
2887        };
2888
2889        let mut state = ReconsolidationState::new();
2890
2891        // Strong reactivation → Labile
2892        state.on_reactivation(1.0, 0.0, &params);
2893        assert!(matches!(state.phase(), MemoryPhase::Labile { .. }));
2894
2895        // After 100s → Restabilizing
2896        state.tick(100.0, &params);
2897        assert!(matches!(state.phase(), MemoryPhase::Restabilizing { .. }));
2898
2899        // After 200s more → Stable with boost
2900        state.tick(300.0, &params);
2901        assert_eq!(*state.phase(), MemoryPhase::Stable);
2902        assert!((state.stability_multiplier() - 1.5).abs() < f64::EPSILON);
2903    }
2904
2905    // ── Dark Nodes (v0.3d) ──────────────────────────────────
2906
2907    #[test]
2908    fn test_dark_node_pass_marks_stale_entities() {
2909        use crate::memory::dark_nodes::DarkNodeParams;
2910
2911        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2912
2913        // Override dark params: immediate silencing (0 delay, threshold 999)
2914        hora.dark_node_params = DarkNodeParams {
2915            silencing_threshold: 999.0, // everything is below this
2916            silencing_delay_secs: 0.0,  // no delay
2917            recovery_threshold: 1.5,
2918            gc_eligible_after_secs: 0.0,
2919        };
2920
2921        let id = hora.add_entity("node", "forgotten", None, None).unwrap();
2922
2923        let count = hora.dark_node_pass();
2924        assert_eq!(count, 1, "Should mark 1 entity as dark");
2925
2926        let phase = hora.get_memory_phase(id).unwrap().clone();
2927        assert!(
2928            matches!(phase, MemoryPhase::Dark { .. }),
2929            "Expected Dark, got {phase:?}"
2930        );
2931    }
2932
2933    #[test]
2934    fn test_dark_node_not_silenced_if_active() {
2935        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2936        let id = hora.add_entity("node", "active", None, None).unwrap();
2937
2938        // Access it many times → high activation
2939        for _ in 0..10 {
2940            hora.record_access(id);
2941        }
2942
2943        // Default threshold is -2.0, activation should be well above
2944        let count = hora.dark_node_pass();
2945        assert_eq!(count, 0, "Active entity should not be silenced");
2946
2947        let phase = hora.get_memory_phase(id).unwrap().clone();
2948        assert_ne!(phase, MemoryPhase::Dark { silenced_at: 0.0 });
2949    }
2950
2951    #[test]
2952    fn test_dark_node_invisible_in_search() {
2953        use crate::memory::dark_nodes::DarkNodeParams;
2954
2955        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2956        hora.dark_node_params = DarkNodeParams {
2957            silencing_threshold: 999.0,
2958            silencing_delay_secs: 0.0,
2959            recovery_threshold: 1.5,
2960            gc_eligible_after_secs: 0.0,
2961        };
2962        // High destabilization threshold so search() side-effect doesn't
2963        // transition entity out of Stable before dark_node_pass
2964        hora.reconsolidation_params.destabilization_threshold = 9999.0;
2965
2966        let _id = hora
2967            .add_entity("node", "invisible ghost", None, None)
2968            .unwrap();
2969
2970        // Before dark_node_pass: entity visible in search
2971        let results = hora
2972            .search(Some("ghost"), None, SearchOpts::default())
2973            .unwrap();
2974        assert_eq!(results.len(), 1, "Should find entity before silencing");
2975
2976        hora.dark_node_pass();
2977
2978        // After dark_node_pass: entity invisible by default
2979        let results = hora
2980            .search(Some("ghost"), None, SearchOpts::default())
2981            .unwrap();
2982        assert_eq!(results.len(), 0, "Dark node should be invisible in search");
2983
2984        // But visible with include_dark=true
2985        let results = hora
2986            .search(
2987                Some("ghost"),
2988                None,
2989                SearchOpts {
2990                    include_dark: true,
2991                    ..Default::default()
2992                },
2993            )
2994            .unwrap();
2995        assert_eq!(
2996            results.len(),
2997            1,
2998            "Dark node should be visible with include_dark"
2999        );
3000    }
3001
3002    #[test]
3003    fn test_dark_node_recovery() {
3004        use crate::memory::dark_nodes::DarkNodeParams;
3005
3006        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3007        hora.dark_node_params = DarkNodeParams {
3008            silencing_threshold: 999.0,
3009            silencing_delay_secs: 0.0,
3010            recovery_threshold: 1.5,
3011            gc_eligible_after_secs: 0.0,
3012        };
3013
3014        let id = hora.add_entity("node", "recoverable", None, None).unwrap();
3015        hora.dark_node_pass();
3016
3017        // Entity is Dark
3018        assert!(matches!(
3019            hora.get_memory_phase(id).unwrap(),
3020            MemoryPhase::Dark { .. }
3021        ));
3022
3023        // Recovery → Labile
3024        let recovered = hora.attempt_recovery(id);
3025        assert!(recovered, "Recovery should succeed for dark node");
3026
3027        let phase = hora.get_memory_phase(id).unwrap().clone();
3028        assert!(
3029            matches!(phase, MemoryPhase::Labile { .. }),
3030            "Expected Labile after recovery, got {phase:?}"
3031        );
3032
3033        // Search should find it again
3034        let results = hora
3035            .search(Some("recoverable"), None, SearchOpts::default())
3036            .unwrap();
3037        assert_eq!(results.len(), 1, "Recovered entity should be searchable");
3038    }
3039
3040    #[test]
3041    fn test_dark_nodes_list() {
3042        use crate::memory::dark_nodes::DarkNodeParams;
3043
3044        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3045        hora.dark_node_params = DarkNodeParams {
3046            silencing_threshold: 999.0,
3047            silencing_delay_secs: 0.0,
3048            recovery_threshold: 1.5,
3049            gc_eligible_after_secs: 0.0,
3050        };
3051
3052        let a = hora.add_entity("node", "alpha", None, None).unwrap();
3053        let b = hora.add_entity("node", "bravo", None, None).unwrap();
3054        hora.dark_node_pass();
3055
3056        let darks = hora.dark_nodes();
3057        assert_eq!(darks.len(), 2);
3058        assert!(darks.contains(&a));
3059        assert!(darks.contains(&b));
3060    }
3061
3062    #[test]
3063    fn test_gc_candidates() {
3064        use crate::memory::dark_nodes::DarkNodeParams;
3065
3066        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3067        hora.dark_node_params = DarkNodeParams {
3068            silencing_threshold: 999.0,
3069            silencing_delay_secs: 0.0,
3070            recovery_threshold: 1.5,
3071            gc_eligible_after_secs: 0.0, // immediate GC eligibility
3072        };
3073
3074        let id = hora.add_entity("node", "ancient", None, None).unwrap();
3075        hora.dark_node_pass();
3076
3077        let gc = hora.gc_candidates();
3078        assert!(
3079            gc.contains(&id),
3080            "Dark entity should be GC candidate with 0s threshold"
3081        );
3082    }
3083
3084    #[test]
3085    fn test_attempt_recovery_on_non_dark_is_noop() {
3086        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3087        let id = hora.add_entity("node", "stable", None, None).unwrap();
3088
3089        let recovered = hora.attempt_recovery(id);
3090        assert!(!recovered, "Recovery on Stable entity should return false");
3091    }
3092
3093    // ── FSRS Scheduling (v0.3e) ─────────────────────────────
3094
3095    #[test]
3096    fn test_fsrs_retrievability_starts_at_1() {
3097        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3098        let id = hora.add_entity("node", "fresh", None, None).unwrap();
3099        // Entity was just created, so retrievability should be ~1.0
3100        let r = hora.get_retrievability(id).unwrap();
3101        assert!(
3102            r > 0.99,
3103            "Retrievability should be ~1.0 right after creation, got {r}"
3104        );
3105    }
3106
3107    #[test]
3108    fn test_fsrs_stability_initial() {
3109        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3110        let id = hora.add_entity("node", "stable", None, None).unwrap();
3111        let s = hora.get_fsrs_stability(id).unwrap();
3112        assert!(
3113            (s - 1.0).abs() < f64::EPSILON,
3114            "Initial stability should be 1.0 day, got {s}"
3115        );
3116    }
3117
3118    #[test]
3119    fn test_fsrs_next_review_days() {
3120        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3121        let id = hora.add_entity("node", "reviewable", None, None).unwrap();
3122        let interval = hora.get_next_review_days(id).unwrap();
3123        // With default r=0.9, interval should ≈ S = 1.0 day
3124        assert!(
3125            (interval - 1.0).abs() < 0.1,
3126            "Next review interval should be ~1 day, got {interval}"
3127        );
3128    }
3129
3130    #[test]
3131    fn test_fsrs_stability_increases_with_access() {
3132        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3133        let id = hora.add_entity("node", "learning", None, None).unwrap();
3134
3135        let s_before = hora.get_fsrs_stability(id).unwrap();
3136
3137        // Multiple accesses → record_review with boost from reconsolidation
3138        for _ in 0..5 {
3139            hora.record_access(id);
3140        }
3141
3142        let s_after = hora.get_fsrs_stability(id).unwrap();
3143        assert!(
3144            s_after >= s_before,
3145            "Stability should not decrease with reviews: before={s_before}, after={s_after}"
3146        );
3147    }
3148
3149    #[test]
3150    fn test_fsrs_none_for_unknown_entity() {
3151        let hora = HoraCore::new(HoraConfig::default()).unwrap();
3152        assert!(hora.get_retrievability(EntityId(9999)).is_none());
3153        assert!(hora.get_next_review_days(EntityId(9999)).is_none());
3154        assert!(hora.get_fsrs_stability(EntityId(9999)).is_none());
3155    }
3156
3157    #[test]
3158    fn test_fsrs_removed_on_delete() {
3159        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3160        let id = hora.add_entity("node", "temp", None, None).unwrap();
3161        assert!(hora.get_retrievability(id).is_some());
3162        hora.delete_entity(id).unwrap();
3163        assert!(hora.get_retrievability(id).is_none());
3164    }
3165
3166    // ── Episode Management (v0.4a) ──────────────────────────
3167
3168    #[test]
3169    fn test_get_episodes_by_session() {
3170        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3171        let e1 = hora.add_entity("node", "A", None, None).unwrap();
3172        hora.add_episode(EpisodeSource::Conversation, "s1", &[e1], &[])
3173            .unwrap();
3174        hora.add_episode(EpisodeSource::Conversation, "s2", &[e1], &[])
3175            .unwrap();
3176        hora.add_episode(EpisodeSource::Conversation, "s1", &[e1], &[])
3177            .unwrap();
3178
3179        let eps = hora.get_episodes(Some("s1"), None, None, None).unwrap();
3180        assert_eq!(eps.len(), 2);
3181        assert!(eps.iter().all(|e| e.session_id == "s1"));
3182    }
3183
3184    #[test]
3185    fn test_get_episodes_by_source() {
3186        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3187        let e1 = hora.add_entity("node", "A", None, None).unwrap();
3188        hora.add_episode(EpisodeSource::Conversation, "s1", &[e1], &[])
3189            .unwrap();
3190        hora.add_episode(EpisodeSource::Document, "s1", &[e1], &[])
3191            .unwrap();
3192        hora.add_episode(EpisodeSource::Api, "s1", &[e1], &[])
3193            .unwrap();
3194
3195        let eps = hora
3196            .get_episodes(None, Some(EpisodeSource::Document), None, None)
3197            .unwrap();
3198        assert_eq!(eps.len(), 1);
3199        assert_eq!(eps[0].source, EpisodeSource::Document);
3200    }
3201
3202    #[test]
3203    fn test_get_episode_by_id() {
3204        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3205        let e1 = hora.add_entity("node", "A", None, None).unwrap();
3206        let ep_id = hora
3207            .add_episode(EpisodeSource::Api, "s1", &[e1], &[])
3208            .unwrap();
3209
3210        let ep = hora.get_episode(ep_id).unwrap().unwrap();
3211        assert_eq!(ep.id, ep_id);
3212        assert_eq!(ep.source, EpisodeSource::Api);
3213    }
3214
3215    #[test]
3216    fn test_consolidation_count_initial_zero() {
3217        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3218        let e1 = hora.add_entity("node", "A", None, None).unwrap();
3219        let ep_id = hora
3220            .add_episode(EpisodeSource::Conversation, "s1", &[e1], &[])
3221            .unwrap();
3222
3223        let ep = hora.get_episode(ep_id).unwrap().unwrap();
3224        assert_eq!(ep.consolidation_count, 0);
3225    }
3226
3227    #[test]
3228    fn test_increment_consolidation() {
3229        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3230        let e1 = hora.add_entity("node", "A", None, None).unwrap();
3231        let ep_id = hora
3232            .add_episode(EpisodeSource::Conversation, "s1", &[e1], &[])
3233            .unwrap();
3234
3235        hora.increment_consolidation(ep_id).unwrap();
3236        hora.increment_consolidation(ep_id).unwrap();
3237
3238        let ep = hora.get_episode(ep_id).unwrap().unwrap();
3239        assert_eq!(ep.consolidation_count, 2);
3240    }
3241
3242    // --- SHY Downscaling ---
3243
3244    #[test]
3245    fn test_shy_downscaling_reduces_activation() {
3246        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3247        let id = hora.add_entity("node", "A", None, None).unwrap();
3248
3249        // Record a few accesses to build up activation
3250        for _ in 0..3 {
3251            hora.record_access(id);
3252        }
3253
3254        let before = hora.get_activation(id).unwrap();
3255        hora.shy_downscaling(0.78);
3256        let after = hora.get_activation(id).unwrap();
3257
3258        // Activation should be reduced by factor 0.78
3259        let expected = before * 0.78;
3260        assert!(
3261            (after - expected).abs() < 1e-10,
3262            "expected {expected}, got {after}"
3263        );
3264    }
3265
3266    #[test]
3267    fn test_shy_downscaling_negative_activation() {
3268        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3269        let id = hora.add_entity("node", "A", None, None).unwrap();
3270
3271        // Entity with only the initial creation access will have negative activation
3272        // after enough time passes, but we can check the factor applies to negatives too.
3273        // Force a known negative by checking: activation at creation is near 0 or negative.
3274        let act = hora.get_activation(id).unwrap();
3275        // Even if activation is positive, after SHY it should be factor × act
3276        hora.shy_downscaling(0.78);
3277        let after = hora.get_activation(id).unwrap();
3278        let expected = act * 0.78;
3279        assert!(
3280            (after - expected).abs() < 1e-10,
3281            "expected {expected}, got {after}"
3282        );
3283    }
3284
3285    #[test]
3286    fn test_shy_double_downscaling() {
3287        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3288        let id = hora.add_entity("node", "A", None, None).unwrap();
3289        for _ in 0..3 {
3290            hora.record_access(id);
3291        }
3292
3293        let before = hora.get_activation(id).unwrap();
3294        hora.shy_downscaling(0.78);
3295        hora.shy_downscaling(0.78);
3296        let after = hora.get_activation(id).unwrap();
3297
3298        // Double SHY: factor² = 0.78 * 0.78 = 0.6084
3299        let expected = before * 0.78 * 0.78;
3300        assert!(
3301            (after - expected).abs() < 1e-10,
3302            "expected {expected}, got {after}"
3303        );
3304    }
3305
3306    #[test]
3307    fn test_shy_downscaling_all_entities() {
3308        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3309        let a = hora.add_entity("node", "A", None, None).unwrap();
3310        let b = hora.add_entity("node", "B", None, None).unwrap();
3311        let c = hora.add_entity("node", "C", None, None).unwrap();
3312
3313        // Give different activation levels
3314        hora.record_access(a);
3315        hora.record_access(b);
3316        hora.record_access(b);
3317        hora.record_access(c);
3318        hora.record_access(c);
3319        hora.record_access(c);
3320
3321        let before_a = hora.get_activation(a).unwrap();
3322        let before_b = hora.get_activation(b).unwrap();
3323        let before_c = hora.get_activation(c).unwrap();
3324
3325        let count = hora.shy_downscaling(0.78);
3326        assert_eq!(count, 3);
3327
3328        let after_a = hora.get_activation(a).unwrap();
3329        let after_b = hora.get_activation(b).unwrap();
3330        let after_c = hora.get_activation(c).unwrap();
3331
3332        assert!((after_a - before_a * 0.78).abs() < 1e-10);
3333        assert!((after_b - before_b * 0.78).abs() < 1e-10);
3334        assert!((after_c - before_c * 0.78).abs() < 1e-10);
3335    }
3336
3337    // --- Interleaved Replay ---
3338
3339    #[test]
3340    fn test_replay_boosts_entity_activation() {
3341        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3342        let a = hora.add_entity("node", "A", None, None).unwrap();
3343        let b = hora.add_entity("node", "B", None, None).unwrap();
3344
3345        hora.add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[])
3346            .unwrap();
3347
3348        let act_a_before = hora.get_activation(a).unwrap();
3349        let act_b_before = hora.get_activation(b).unwrap();
3350
3351        let stats = hora.interleaved_replay().unwrap();
3352        assert_eq!(stats.episodes_replayed, 1);
3353        assert_eq!(stats.entities_reactivated, 2);
3354
3355        let act_a_after = hora.get_activation(a).unwrap();
3356        let act_b_after = hora.get_activation(b).unwrap();
3357
3358        assert!(
3359            act_a_after > act_a_before,
3360            "A activation should increase after replay"
3361        );
3362        assert!(
3363            act_b_after > act_b_before,
3364            "B activation should increase after replay"
3365        );
3366    }
3367
3368    #[test]
3369    fn test_replay_respects_max_items() {
3370        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3371        hora.consolidation_params.max_replay_items = 3;
3372        let e = hora.add_entity("node", "A", None, None).unwrap();
3373
3374        for i in 0..10 {
3375            hora.add_episode(EpisodeSource::Conversation, &format!("s{i}"), &[e], &[])
3376                .unwrap();
3377        }
3378
3379        let stats = hora.interleaved_replay().unwrap();
3380        assert_eq!(stats.episodes_replayed, 3);
3381    }
3382
3383    #[test]
3384    fn test_replay_mix_recent_and_older() {
3385        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3386        hora.consolidation_params.max_replay_items = 10;
3387        hora.consolidation_params.recent_ratio = 0.7;
3388        let e = hora.add_entity("node", "A", None, None).unwrap();
3389
3390        // Create 20 episodes — first 10 are "older", last 10 are "recent"
3391        for i in 0..20 {
3392            hora.add_episode(EpisodeSource::Conversation, &format!("s{i}"), &[e], &[])
3393                .unwrap();
3394        }
3395
3396        let stats = hora.interleaved_replay().unwrap();
3397        // Budget: 10 total, ceil(10 * 0.7) = 7 recent, 3 older
3398        assert_eq!(stats.episodes_replayed, 10);
3399        assert_eq!(stats.entities_reactivated, 10);
3400    }
3401
3402    #[test]
3403    fn test_replay_ignores_deleted_entities() {
3404        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3405        let a = hora.add_entity("node", "A", None, None).unwrap();
3406        let b = hora.add_entity("node", "B", None, None).unwrap();
3407
3408        hora.add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[])
3409            .unwrap();
3410
3411        // Delete entity B
3412        hora.delete_entity(b).unwrap();
3413
3414        let stats = hora.interleaved_replay().unwrap();
3415        assert_eq!(stats.episodes_replayed, 1);
3416        // Only A should be reactivated (B was deleted)
3417        assert_eq!(stats.entities_reactivated, 1);
3418    }
3419
3420    #[test]
3421    fn test_replay_empty_episodes() {
3422        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3423        let stats = hora.interleaved_replay().unwrap();
3424        assert_eq!(stats.episodes_replayed, 0);
3425        assert_eq!(stats.entities_reactivated, 0);
3426    }
3427
3428    // --- CLS Transfer ---
3429
3430    #[test]
3431    fn test_cls_transfer_creates_semantic_fact() {
3432        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3433        hora.consolidation_params.cls_threshold = 3;
3434
3435        let a = hora.add_entity("person", "Alice", None, None).unwrap();
3436        let b = hora.add_entity("person", "Bob", None, None).unwrap();
3437
3438        // Create the same fact in 3 different episodes
3439        let f1 = hora
3440            .add_fact(a, b, "knows", "they know each other", None)
3441            .unwrap();
3442        let f2 = hora.add_fact(a, b, "knows", "met at work", None).unwrap();
3443        let f3 = hora.add_fact(a, b, "knows", "colleagues", None).unwrap();
3444
3445        // Create 3 episodes each referencing one of these facts, with consolidation_count >= 3
3446        let ep1 = hora
3447            .add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[f1])
3448            .unwrap();
3449        let ep2 = hora
3450            .add_episode(EpisodeSource::Conversation, "s2", &[a, b], &[f2])
3451            .unwrap();
3452        let ep3 = hora
3453            .add_episode(EpisodeSource::Conversation, "s3", &[a, b], &[f3])
3454            .unwrap();
3455
3456        // Manually set consolidation_count to threshold
3457        for _ in 0..3 {
3458            hora.increment_consolidation(ep1).unwrap();
3459            hora.increment_consolidation(ep2).unwrap();
3460            hora.increment_consolidation(ep3).unwrap();
3461        }
3462
3463        let stats = hora.cls_transfer().unwrap();
3464        assert_eq!(stats.episodes_processed, 3);
3465        // The triplet (a, "knows", b) appears in 3 episodes → creates 1 semantic fact
3466        // But 3 existing edges already match, so it reinforces the first one found
3467        assert!(stats.facts_created + stats.facts_reinforced > 0);
3468    }
3469
3470    #[test]
3471    fn test_cls_transfer_below_threshold_skipped() {
3472        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3473        hora.consolidation_params.cls_threshold = 3;
3474
3475        let a = hora.add_entity("person", "Alice", None, None).unwrap();
3476        let b = hora.add_entity("person", "Bob", None, None).unwrap();
3477        let f1 = hora.add_fact(a, b, "knows", "friends", None).unwrap();
3478
3479        // Only 2 episodes — below threshold
3480        let ep1 = hora
3481            .add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[f1])
3482            .unwrap();
3483        let ep2 = hora
3484            .add_episode(EpisodeSource::Conversation, "s2", &[a, b], &[f1])
3485            .unwrap();
3486
3487        for _ in 0..3 {
3488            hora.increment_consolidation(ep1).unwrap();
3489            hora.increment_consolidation(ep2).unwrap();
3490        }
3491
3492        let stats = hora.cls_transfer().unwrap();
3493        // 2 episodes processed but triplet count (2) < threshold (3)
3494        assert_eq!(stats.episodes_processed, 2);
3495        assert_eq!(stats.facts_created, 0);
3496        assert_eq!(stats.facts_reinforced, 0);
3497    }
3498
3499    #[test]
3500    fn test_cls_transfer_reinforces_existing() {
3501        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3502        hora.consolidation_params.cls_threshold = 3;
3503
3504        let a = hora.add_entity("person", "Alice", None, None).unwrap();
3505        let b = hora.add_entity("person", "Bob", None, None).unwrap();
3506
3507        // Create ONE canonical fact
3508        let fact_id = hora.add_fact(a, b, "knows", "friends", Some(0.8)).unwrap();
3509
3510        // Reference the same fact_id in 3 episodes
3511        let ep1 = hora
3512            .add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[fact_id])
3513            .unwrap();
3514        let ep2 = hora
3515            .add_episode(EpisodeSource::Conversation, "s2", &[a, b], &[fact_id])
3516            .unwrap();
3517        let ep3 = hora
3518            .add_episode(EpisodeSource::Conversation, "s3", &[a, b], &[fact_id])
3519            .unwrap();
3520
3521        for _ in 0..3 {
3522            hora.increment_consolidation(ep1).unwrap();
3523            hora.increment_consolidation(ep2).unwrap();
3524            hora.increment_consolidation(ep3).unwrap();
3525        }
3526
3527        let stats = hora.cls_transfer().unwrap();
3528        assert_eq!(stats.episodes_processed, 3);
3529        // Existing edge found → reinforce
3530        assert_eq!(stats.facts_reinforced, 1);
3531        assert_eq!(stats.facts_created, 0);
3532
3533        // Confidence should have increased from 0.8 to 0.9
3534        let edge = hora.get_fact(fact_id).unwrap().unwrap();
3535        assert!((edge.confidence - 0.9).abs() < 1e-6);
3536    }
3537
3538    #[test]
3539    fn test_cls_transfer_increments_consolidation() {
3540        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3541        hora.consolidation_params.cls_threshold = 3;
3542
3543        let a = hora.add_entity("person", "Alice", None, None).unwrap();
3544        let b = hora.add_entity("person", "Bob", None, None).unwrap();
3545        let f = hora.add_fact(a, b, "knows", "friends", None).unwrap();
3546
3547        let ep = hora
3548            .add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[f])
3549            .unwrap();
3550        // Set to exactly threshold
3551        for _ in 0..3 {
3552            hora.increment_consolidation(ep).unwrap();
3553        }
3554
3555        let before = hora.get_episode(ep).unwrap().unwrap().consolidation_count;
3556        hora.cls_transfer().unwrap();
3557        let after = hora.get_episode(ep).unwrap().unwrap().consolidation_count;
3558
3559        assert_eq!(after, before + 1);
3560    }
3561
3562    // --- Memory Linking ---
3563
3564    #[test]
3565    fn test_memory_linking_creates_bidirectional_links() {
3566        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3567        // Entities created in quick succession → within any reasonable window
3568        let a = hora.add_entity("node", "A", None, None).unwrap();
3569        let b = hora.add_entity("node", "B", None, None).unwrap();
3570
3571        let stats = hora.memory_linking().unwrap();
3572        // A→B and B→A
3573        assert_eq!(stats.links_created, 2);
3574        assert_eq!(stats.links_reinforced, 0);
3575
3576        // Verify edges exist
3577        let edges_a = hora.get_entity_facts(a).unwrap();
3578        assert!(edges_a
3579            .iter()
3580            .any(|e| e.target == b && e.relation_type == "temporally_linked"));
3581        let edges_b = hora.get_entity_facts(b).unwrap();
3582        assert!(edges_b
3583            .iter()
3584            .any(|e| e.target == a && e.relation_type == "temporally_linked"));
3585    }
3586
3587    #[test]
3588    fn test_memory_linking_outside_window_no_link() {
3589        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3590        // Set window to 0ms → no entities can be within window
3591        hora.consolidation_params.linking_window_ms = 0;
3592
3593        let _a = hora.add_entity("node", "A", None, None).unwrap();
3594        let _b = hora.add_entity("node", "B", None, None).unwrap();
3595
3596        let stats = hora.memory_linking().unwrap();
3597        assert_eq!(stats.links_created, 0);
3598    }
3599
3600    #[test]
3601    fn test_memory_linking_reinforces_existing() {
3602        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3603        let _a = hora.add_entity("node", "A", None, None).unwrap();
3604        let _b = hora.add_entity("node", "B", None, None).unwrap();
3605
3606        // First pass: create links
3607        let stats1 = hora.memory_linking().unwrap();
3608        assert_eq!(stats1.links_created, 2);
3609
3610        // Second pass: reinforce (same entities, links already exist)
3611        let stats2 = hora.memory_linking().unwrap();
3612        assert_eq!(stats2.links_created, 0);
3613        assert_eq!(stats2.links_reinforced, 2);
3614    }
3615
3616    #[test]
3617    fn test_memory_linking_combinatoric() {
3618        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3619        // Create 4 entities in quick succession (all within window)
3620        hora.add_entity("node", "A", None, None).unwrap();
3621        hora.add_entity("node", "B", None, None).unwrap();
3622        hora.add_entity("node", "C", None, None).unwrap();
3623        hora.add_entity("node", "D", None, None).unwrap();
3624
3625        let stats = hora.memory_linking().unwrap();
3626        // 4 entities → C(4,2) = 6 pairs × 2 directions = 12 links
3627        assert_eq!(stats.links_created, 12);
3628    }
3629
3630    // --- Dream Cycle ---
3631
3632    #[test]
3633    fn test_dream_cycle_executes_all_steps() {
3634        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3635        let a = hora.add_entity("node", "A", None, None).unwrap();
3636        let b = hora.add_entity("node", "B", None, None).unwrap();
3637        hora.add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[])
3638            .unwrap();
3639
3640        let config = DreamCycleConfig::default();
3641        let stats = hora.dream_cycle(&config).unwrap();
3642
3643        // SHY should have downscaled 2 entities
3644        assert_eq!(stats.entities_downscaled, 2);
3645        // Replay should have processed 1 episode
3646        assert_eq!(stats.replay.episodes_replayed, 1);
3647        // Linking should have created temporal links
3648        assert!(stats.linking.links_created > 0);
3649    }
3650
3651    #[test]
3652    fn test_dream_cycle_disable_steps() {
3653        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3654        hora.add_entity("node", "A", None, None).unwrap();
3655        hora.add_entity("node", "B", None, None).unwrap();
3656
3657        let config = DreamCycleConfig {
3658            shy: false,
3659            replay: false,
3660            cls: false,
3661            linking: false,
3662            dark_check: false,
3663            gc: false,
3664        };
3665
3666        let stats = hora.dream_cycle(&config).unwrap();
3667        assert_eq!(stats.entities_downscaled, 0);
3668        assert_eq!(stats.replay.episodes_replayed, 0);
3669        assert_eq!(stats.cls.episodes_processed, 0);
3670        assert_eq!(stats.linking.links_created, 0);
3671        assert_eq!(stats.dark_nodes_marked, 0);
3672        assert_eq!(stats.gc_deleted, 0);
3673    }
3674
3675    #[test]
3676    fn test_dream_cycle_idempotent_no_duplicates() {
3677        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3678        let a = hora.add_entity("node", "A", None, None).unwrap();
3679        let b = hora.add_entity("node", "B", None, None).unwrap();
3680        hora.add_episode(EpisodeSource::Conversation, "s1", &[a, b], &[])
3681            .unwrap();
3682
3683        let config = DreamCycleConfig::default();
3684        let stats1 = hora.dream_cycle(&config).unwrap();
3685        let stats2 = hora.dream_cycle(&config).unwrap();
3686
3687        // Second call: linking should reinforce, not create new links
3688        assert_eq!(stats2.linking.links_created, 0);
3689        // First call created links, second reinforced them
3690        assert!(stats1.linking.links_created > 0);
3691        assert!(stats2.linking.links_reinforced > 0);
3692    }
3693
3694    #[test]
3695    fn test_dream_cycle_stats_coherent() {
3696        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3697        let a = hora.add_entity("node", "A", None, None).unwrap();
3698        let _b = hora.add_entity("node", "B", None, None).unwrap();
3699        let _c = hora.add_entity("node", "C", None, None).unwrap();
3700        hora.add_episode(EpisodeSource::Conversation, "s1", &[a], &[])
3701            .unwrap();
3702
3703        let config = DreamCycleConfig::default();
3704        let stats = hora.dream_cycle(&config).unwrap();
3705
3706        // 3 entities downscaled
3707        assert_eq!(stats.entities_downscaled, 3);
3708        // 1 episode replayed with 1 entity
3709        assert_eq!(stats.replay.episodes_replayed, 1);
3710        assert_eq!(stats.replay.entities_reactivated, 1);
3711        // No GC by default
3712        assert_eq!(stats.gc_deleted, 0);
3713    }
3714
3715    #[test]
3716    fn test_episodes_sorted_by_created_at() {
3717        let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3718        let e1 = hora.add_entity("node", "A", None, None).unwrap();
3719
3720        hora.add_episode(EpisodeSource::Conversation, "s1", &[e1], &[])
3721            .unwrap();
3722        hora.add_episode(EpisodeSource::Conversation, "s2", &[e1], &[])
3723            .unwrap();
3724        hora.add_episode(EpisodeSource::Conversation, "s3", &[e1], &[])
3725            .unwrap();
3726
3727        let eps = hora.get_episodes(None, None, None, None).unwrap();
3728        assert_eq!(eps.len(), 3);
3729        // Should be sorted by created_at
3730        for w in eps.windows(2) {
3731            assert!(w[0].created_at <= w[1].created_at);
3732        }
3733    }
3734}