1pub 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
43pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 self.reconsolidation_states
306 .insert(id, ReconsolidationState::new());
307
308 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 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 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 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 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 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 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 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 pub fn get_fact(&self, id: EdgeId) -> Result<Option<Edge>> {
437 self.storage.get_edge(id)
438 }
439
440 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 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 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 pub fn get_entity_facts(&self, entity_id: EntityId) -> Result<Vec<Edge>> {
483 self.storage.get_entity_edges(entity_id)
484 }
485
486 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 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 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 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 neighbor_ids.remove(&entity_id);
556 Ok(neighbor_ids.into_iter().collect())
557 }
558
559 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 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 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 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 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 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 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 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 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 for hit in &results {
680 self.record_access(hit.entity_id);
681 }
682
683 Ok(results)
684 }
685
686 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 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 if let Some(recon) = self.reconsolidation_states.get_mut(&id) {
712 recon.on_reactivation(activation, now, &self.reconsolidation_params);
713 }
714
715 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 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 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 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 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 pub fn get_fsrs_stability(&self, id: EntityId) -> Option<f64> {
782 self.fsrs_states.get(&id).map(|fsrs| fsrs.stability_days())
783 }
784
785 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 if activation >= params.silencing_threshold {
807 continue;
808 }
809
810 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 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 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 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 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 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 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 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 all_episodes.sort_by_key(|e| e.created_at);
934
935 let mid = all_episodes.len() / 2;
937 let (older, recent) = all_episodes.split_at(mid);
938
939 let recent_budget = ((max as f64) * ratio).ceil() as usize;
941 let older_budget = max.saturating_sub(recent_budget);
942
943 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 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 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 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 let mut triplet_counts: HashMap<(EntityId, String, EntityId), u32> = HashMap::new();
995
996 for ep in &eligible {
997 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 ((source, relation, target), count) in &triplet_counts {
1014 if *count < threshold {
1015 continue;
1016 }
1017
1018 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 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 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 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 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 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 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; }
1111
1112 let a = entities[i].id;
1113 let b = ej.id;
1114
1115 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 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 pub fn dream_cycle(&mut self, config: &DreamCycleConfig) -> Result<DreamCycleStats> {
1191 self.flush_accesses();
1192 let entities_downscaled = if config.shy {
1194 self.shy_downscaling(self.consolidation_params.shy_factor)
1195 } else {
1196 0
1197 };
1198
1199 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 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 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 let dark_nodes_marked = if config.dark_check {
1232 self.dark_node_pass()
1233 } else {
1234 0
1235 };
1236
1237 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 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 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 pub fn get_episode(&self, id: u64) -> Result<Option<Episode>> {
1319 self.storage.get_episode(id)
1320 }
1321
1322 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 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 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 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); 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]; 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 #[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"); }
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 assert!(hora.get_entity(a).unwrap().is_none());
1585 assert!(hora.get_fact(fact_id).unwrap().is_none());
1587 assert!(hora.get_entity(b).unwrap().is_some());
1589 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 }
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 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"); }
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); }
1706
1707 #[test]
1710 fn test_bfs_depth_2() {
1711 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); }
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 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 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 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 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 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 let e1 = hora.get_fact(f1).unwrap().unwrap();
1835 let e2 = hora.get_fact(f2).unwrap().unwrap();
1836
1837 hora.invalidate_fact(f1).unwrap();
1839 let e1_after = hora.get_fact(f1).unwrap().unwrap();
1840
1841 let before = hora.facts_at(e1.valid_at - 1).unwrap();
1843 assert_eq!(before.len(), 0);
1844
1845 let mid = hora.facts_at(e2.valid_at).unwrap();
1850 assert!(mid.iter().any(|e| e.id == f2));
1852
1853 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 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 #[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 {
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(); hora.add_entity("b", "second", None, None).unwrap(); 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 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 {
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 #[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 hora.add_entity("a", "close", None, Some(&[1.0, 0.0, 0.0]))
2097 .unwrap();
2098 hora.add_entity("b", "far", None, Some(&[0.0, 1.0, 0.0]))
2100 .unwrap();
2101 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 assert_eq!(results[0].entity_id, EntityId(1));
2110 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 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 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 #[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 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 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 {
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 #[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 hora.add_entity("a", "rust language", None, Some(&[1.0, 0.0, 0.0]))
2343 .unwrap();
2344 hora.add_entity("b", "rust compiler", None, Some(&[0.0, 1.0, 0.0]))
2346 .unwrap();
2347 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 assert_eq!(results[0].entity_id, EntityId(1));
2364 assert!(results.len() >= 2);
2365 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 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 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 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 #[test]
2479 fn test_dedup_name_exact_normalization() {
2480 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 assert_eq!(id1, id2);
2492 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 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]; 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 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]; 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 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 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(); 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 let config = HoraConfig {
2600 dedup: DedupConfig {
2601 enabled: true,
2602 name_exact: false, jaccard_threshold: 0.5,
2604 cosine_threshold: 0.0,
2605 },
2606 ..Default::default()
2607 };
2608 let mut hora = HoraCore::new(config).unwrap();
2609
2610 let id1 = hora
2612 .add_entity("project", "rust graph engine", None, None)
2613 .unwrap();
2614 let id2 = hora
2616 .add_entity("project", "rust graph database", None, None)
2617 .unwrap();
2618
2619 assert_eq!(id1, id2);
2620 }
2621
2622 #[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 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 let _ = hora.get_entity(id).unwrap();
2643 let act_after = hora.get_activation(id).unwrap();
2644
2645 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 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 #[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)], ¶ms).unwrap();
2711
2712 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 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)], ¶ms).unwrap();
2734
2735 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)], ¶ms).unwrap();
2761
2762 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)], ¶ms)
2785 .unwrap();
2786
2787 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)], ¶ms).unwrap();
2802
2803 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 let result = hora.spread_activation(&[(a, 1.0)], ¶ms).unwrap();
2818 assert!(result.contains_key(&a));
2819 assert!(result.contains_key(&b));
2820 }
2821
2822 #[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 let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
2855 let id = hora.add_entity("node", "A", None, None).unwrap();
2856
2857 for _ in 0..5 {
2860 let _ = hora.get_entity(id);
2861 }
2862
2863 let activation = hora.get_activation(id).unwrap();
2865
2866 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 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, restabilization_boost: 1.5,
2887 };
2888
2889 let mut state = ReconsolidationState::new();
2890
2891 state.on_reactivation(1.0, 0.0, ¶ms);
2893 assert!(matches!(state.phase(), MemoryPhase::Labile { .. }));
2894
2895 state.tick(100.0, ¶ms);
2897 assert!(matches!(state.phase(), MemoryPhase::Restabilizing { .. }));
2898
2899 state.tick(300.0, ¶ms);
2901 assert_eq!(*state.phase(), MemoryPhase::Stable);
2902 assert!((state.stability_multiplier() - 1.5).abs() < f64::EPSILON);
2903 }
2904
2905 #[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 hora.dark_node_params = DarkNodeParams {
2915 silencing_threshold: 999.0, silencing_delay_secs: 0.0, 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 for _ in 0..10 {
2940 hora.record_access(id);
2941 }
2942
2943 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 hora.reconsolidation_params.destabilization_threshold = 9999.0;
2965
2966 let _id = hora
2967 .add_entity("node", "invisible ghost", None, None)
2968 .unwrap();
2969
2970 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 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 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 assert!(matches!(
3019 hora.get_memory_phase(id).unwrap(),
3020 MemoryPhase::Dark { .. }
3021 ));
3022
3023 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 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, };
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 #[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 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 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 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 #[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 #[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 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 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 let act = hora.get_activation(id).unwrap();
3275 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 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 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 #[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 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 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 hora.delete_entity(b).unwrap();
3413
3414 let stats = hora.interleaved_replay().unwrap();
3415 assert_eq!(stats.episodes_replayed, 1);
3416 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 #[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 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 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 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 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 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 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 let fact_id = hora.add_fact(a, b, "knows", "friends", Some(0.8)).unwrap();
3509
3510 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 assert_eq!(stats.facts_reinforced, 1);
3531 assert_eq!(stats.facts_created, 0);
3532
3533 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 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 #[test]
3565 fn test_memory_linking_creates_bidirectional_links() {
3566 let mut hora = HoraCore::new(HoraConfig::default()).unwrap();
3567 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 assert_eq!(stats.links_created, 2);
3574 assert_eq!(stats.links_reinforced, 0);
3575
3576 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 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 let stats1 = hora.memory_linking().unwrap();
3608 assert_eq!(stats1.links_created, 2);
3609
3610 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 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 assert_eq!(stats.links_created, 12);
3628 }
3629
3630 #[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 assert_eq!(stats.entities_downscaled, 2);
3645 assert_eq!(stats.replay.episodes_replayed, 1);
3647 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 assert_eq!(stats2.linking.links_created, 0);
3689 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 assert_eq!(stats.entities_downscaled, 3);
3708 assert_eq!(stats.replay.episodes_replayed, 1);
3710 assert_eq!(stats.replay.entities_reactivated, 1);
3711 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 for w in eps.windows(2) {
3731 assert!(w[0].created_at <= w[1].created_at);
3732 }
3733 }
3734}