1use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13use std::time::Instant;
14
15use chrono::Utc;
16use nexus_core::config::{AgentConfig, CognitionConfig};
17use nexus_core::traits::EmbeddingService;
18use nexus_core::{
19 CognitiveLevel, CognitiveMetadata, Memory, MemoryCategory, MemoryLaneCognitiveType,
20 MemoryLanePriorityType, MemoryLaneType, PerspectiveKey,
21};
22use nexus_storage::repository::{
23 ListMemoryFilters, MemoryRepository, StoreMemoryParams, StoreMemoryWithLineageParams,
24};
25use tracing::{debug, info};
26
27use crate::error::AgentError;
28use crate::util::{flush_metric_samples, maybe_embed, stage_metric_sample, CognitionSnapshot};
29
30const REFLECT_GENERATED_BY: &str = "reflect_service";
35const REINFORCE_EVIDENCE_ROLE: &str = "reinforces";
36const CONTRADICT_EVIDENCE_ROLE: &str = "contradicts";
37const INSIGHT_EVIDENCE_ROLE: &str = "insight_support";
38const MAX_CANDIDATES: i64 = 100;
39const MIN_INSIGHT_COMPONENT_SIZE: usize = 3;
40const MAX_INSIGHT_CONTENT_CHARS: usize = 180;
41
42const REINFORCE_SIMILARITY_THRESHOLD: f32 = 0.80;
44const INSIGHT_SIMILARITY_THRESHOLD: f32 = 0.55;
45
46const CONTRADICTION_MIN_TOPIC_OVERLAP: f32 = 0.30;
48
49const NEGATION_WORDS: &[&str] = &[
51 "not",
52 "no",
53 "never",
54 "don't",
55 "doesn't",
56 "can't",
57 "cannot",
58 "won't",
59 "isn't",
60 "aren't",
61 "wasn't",
62 "weren't",
63 "shouldn't",
64 "wouldn't",
65 "couldn't",
66];
67
68const STOP_WORDS: &[&str] = &[
70 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
71 "do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can", "to",
72 "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "through", "during",
73 "before", "after", "above", "below", "between", "and", "but", "or", "nor", "if", "then",
74 "that", "this", "these", "those", "it", "its", "we", "our", "they", "their", "he", "she",
75 "his", "her", "my", "your",
76];
77
78#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ReflectionCase {
85 Reinforcement,
87 Contradiction,
89}
90
91#[derive(Debug, Clone)]
93pub struct ReflectionOutput {
94 pub case: ReflectionCase,
95 pub left_id: i64,
96 pub right_id: i64,
97 pub similarity: f32,
98}
99
100#[derive(Debug, Clone, Default)]
102pub struct ReflectionResult {
103 pub memories_scanned: usize,
104 pub pairs_compared: usize,
105 pub reinforcements: usize,
106 pub insights_created: usize,
107 pub contradictions_created: usize,
108 pub insight_ids: Vec<i64>,
109 pub contradiction_ids: Vec<i64>,
110}
111
112pub struct ReflectService {
122 _config: AgentConfig,
123 cognition: CognitionConfig,
124 embeddings: Option<Arc<dyn EmbeddingService>>,
125}
126
127impl ReflectService {
128 pub fn new(
129 config: AgentConfig,
130 cognition: CognitionConfig,
131 embeddings: Option<Arc<dyn EmbeddingService>>,
132 ) -> Self {
133 Self {
134 _config: config,
135 cognition,
136 embeddings,
137 }
138 }
139
140 pub async fn reflect_cycle(
146 &self,
147 namespace_id: i64,
148 repo: &MemoryRepository,
149 ) -> Result<ReflectionResult, AgentError> {
150 let started = Instant::now();
151 let groups = gather_candidates(namespace_id, repo).await?;
152 let scanned: usize = groups.values().map(Vec::len).sum();
153 if scanned < 2 {
154 debug!(namespace_id, "Not enough candidates for reflection");
155 return Ok(ReflectionResult::default());
156 }
157
158 let mut result = ReflectionResult {
159 memories_scanned: scanned,
160 ..Default::default()
161 };
162
163 for (perspective, candidates) in groups {
164 let group_result = run_reflection_group(
165 candidates,
166 &perspective,
167 repo,
168 self.embeddings.as_deref(),
169 &self.cognition,
170 )
171 .await?;
172 result.pairs_compared += group_result.pairs_compared;
173 result.reinforcements += group_result.reinforcements;
174 result.insights_created += group_result.insights_created;
175 result.contradictions_created += group_result.contradictions_created;
176 result.insight_ids.extend(group_result.insight_ids);
177 result
178 .contradiction_ids
179 .extend(group_result.contradiction_ids);
180 }
181
182 info!(
183 namespace_id,
184 scanned,
185 pairs = result.pairs_compared,
186 reinforcements = result.reinforcements,
187 contradictions = result.contradictions_created,
188 "Reflection cycle complete"
189 );
190 flush_metric_samples(
191 repo,
192 &[stage_metric_sample(
193 namespace_id,
194 "cognition.reflect.total_ms",
195 started.elapsed().as_secs_f64() * 1000.0,
196 "reflect_total",
197 )],
198 )
199 .await;
200
201 Ok(result)
202 }
203
204 pub async fn reflect_perspective_cycle(
205 &self,
206 namespace_id: i64,
207 perspective: &PerspectiveKey,
208 repo: &MemoryRepository,
209 ) -> Result<ReflectionResult, AgentError> {
210 let groups = gather_candidates(namespace_id, repo).await?;
211 let candidates = groups.get(perspective).cloned().unwrap_or_default();
212 run_reflection_group(
213 candidates,
214 perspective,
215 repo,
216 self.embeddings.as_deref(),
217 &self.cognition,
218 )
219 .await
220 }
221
222 pub fn compare_pair(left: &Memory, right: &Memory) -> Option<ReflectionCase> {
227 compare_pair(left, right)
228 }
229}
230
231async fn run_reflection_group(
232 candidates: Vec<Memory>,
233 perspective: &PerspectiveKey,
234 repo: &MemoryRepository,
235 embeddings: Option<&dyn EmbeddingService>,
236 cognition: &CognitionConfig,
237) -> Result<ReflectionResult, AgentError> {
238 let scanned = candidates.len();
239 if scanned < 2 {
240 return Ok(ReflectionResult {
241 memories_scanned: scanned,
242 ..Default::default()
243 });
244 }
245
246 let existing_links = load_pair_evidence(repo, &candidates).await?;
247 let mut result = ReflectionResult {
248 memories_scanned: scanned,
249 ..Default::default()
250 };
251 let mut reinforcement_pairs = Vec::new();
252
253 for i in 0..candidates.len() {
254 for j in (i + 1)..candidates.len() {
255 let left = &candidates[i];
256 let right = &candidates[j];
257 result.pairs_compared += 1;
258
259 let pair_key = ordered_pair_key(left.id, right.id);
260 if existing_links.contains(&pair_key) {
261 continue;
262 }
263
264 match compare_pair(left, right) {
265 Some(ReflectionCase::Reinforcement) => {
266 handle_reinforcement(left, right, perspective, repo, embeddings).await?;
267 reinforcement_pairs.push((left.id, right.id));
268 result.reinforcements += 1;
269 }
270 Some(ReflectionCase::Contradiction) => {
271 let contradiction_id =
272 handle_contradiction(left, right, perspective, repo, embeddings, cognition)
273 .await?;
274 result.contradiction_ids.push(contradiction_id);
275 result.contradictions_created += 1;
276 }
277 None => {}
278 }
279 }
280 }
281
282 let insight_ids = synthesize_reinforcement_insights(
283 &candidates,
284 &reinforcement_pairs,
285 perspective,
286 repo,
287 embeddings,
288 )
289 .await?;
290 result.insights_created = insight_ids.len();
291 result.insight_ids = insight_ids;
292
293 Ok(result)
294}
295
296async fn gather_candidates(
301 namespace_id: i64,
302 repo: &MemoryRepository,
303) -> Result<HashMap<PerspectiveKey, Vec<Memory>>, AgentError> {
304 let all = repo
305 .list_filtered(
306 namespace_id,
307 ListMemoryFilters {
308 category: None,
309 since: None,
310 until: None,
311 content_like: None,
312 include_raw: false,
313 limit: MAX_CANDIDATES,
314 offset: 0,
315 },
316 )
317 .await
318 .map_err(|e| AgentError::Storage(e.to_string()))?;
319
320 let mut candidates: HashMap<PerspectiveKey, Vec<Memory>> = HashMap::new();
322 for memory in all {
323 let snapshot = CognitionSnapshot::from_memory(&memory);
324 if matches!(
325 snapshot.level,
326 CognitiveLevel::Explicit | CognitiveLevel::Derived
327 ) && !is_reflection_generated(&snapshot, &memory)
328 {
329 if let Some(perspective) = snapshot.perspective {
330 candidates.entry(perspective).or_default().push(memory);
331 }
332 }
333 }
334
335 Ok(candidates)
336}
337
338fn is_reflection_generated(snapshot: &CognitionSnapshot, memory: &Memory) -> bool {
339 memory.labels.iter().any(|label| label == "reflection")
340 || snapshot
341 .generated_by
342 .as_deref()
343 .map(|generated_by| generated_by == REFLECT_GENERATED_BY)
344 .unwrap_or(false)
345}
346
347type PairKey = (i64, i64);
352
353fn ordered_pair_key(a: i64, b: i64) -> PairKey {
354 if a <= b {
355 (a, b)
356 } else {
357 (b, a)
358 }
359}
360
361async fn load_pair_evidence(
369 repo: &MemoryRepository,
370 candidates: &[Memory],
371) -> Result<HashSet<PairKey>, AgentError> {
372 let candidate_ids: Vec<i64> = candidates.iter().map(|memory| memory.id).collect();
373 let lineage_by_memory = repo
374 .load_lineage_batch(&candidate_ids)
375 .await
376 .map_err(|e| AgentError::Storage(e.to_string()))?;
377
378 let mut reflection_to_sources: HashMap<i64, Vec<i64>> = HashMap::new();
380
381 for mem in candidates {
382 if let Some(lineage) = lineage_by_memory.get(&mem.id) {
383 for entry in lineage {
384 let role = entry.evidence_role.to_lowercase();
385 if role == REINFORCE_EVIDENCE_ROLE || role == CONTRADICT_EVIDENCE_ROLE {
386 let reflection_id = entry.derived_memory_id;
387 let source_id = entry.source_memory_id;
388 reflection_to_sources
396 .entry(reflection_id)
397 .or_default()
398 .push(source_id);
399 }
400 }
401 }
402 }
403
404 let mut seen = HashSet::new();
406 for sources in reflection_to_sources.values() {
407 for i in 0..sources.len() {
408 for j in (i + 1)..sources.len() {
409 seen.insert(ordered_pair_key(sources[i], sources[j]));
410 }
411 }
412 }
413 Ok(seen)
414}
415
416fn compare_pair(left: &Memory, right: &Memory) -> Option<ReflectionCase> {
421 let similarity = word_jaccard(&left.content, &right.content);
422
423 if similarity >= REINFORCE_SIMILARITY_THRESHOLD {
425 return Some(ReflectionCase::Reinforcement);
426 }
427
428 if similarity >= CONTRADICTION_MIN_TOPIC_OVERLAP
430 && has_negation_contradiction(&left.content, &right.content)
431 {
432 return Some(ReflectionCase::Contradiction);
433 }
434
435 None
436}
437
438fn word_jaccard(a: &str, b: &str) -> f32 {
439 let set_a: HashSet<&str> = a.split_whitespace().collect();
440 let set_b: HashSet<&str> = b.split_whitespace().collect();
441
442 if set_a.is_empty() && set_b.is_empty() {
443 return 1.0;
444 }
445 if set_a.is_empty() || set_b.is_empty() {
446 return 0.0;
447 }
448
449 let intersection: usize = set_a.intersection(&set_b).count();
450 let union: usize = set_a.union(&set_b).count();
451
452 intersection as f32 / union as f32
453}
454
455fn has_negation_contradiction(a: &str, b: &str) -> bool {
456 let words_a: Vec<&str> = a.split_whitespace().collect();
457 let words_b: Vec<&str> = b.split_whitespace().collect();
458
459 has_negation_in_other(&words_a, &words_b) || has_negation_in_other(&words_b, &words_a)
460}
461
462fn has_negation_in_other(base_words: &[&str], other_words: &[&str]) -> bool {
467 let negation_set: HashSet<&str> = NEGATION_WORDS.iter().copied().collect();
468 let other_set: HashSet<&str> = other_words.iter().copied().collect();
469
470 for (i, word) in base_words.iter().enumerate() {
471 if negation_set.contains(word) {
472 for offset in 1..=2 {
474 if i + offset < base_words.len() {
475 let target = base_words[i + offset];
476 if !STOP_WORDS.contains(&target)
477 && !negation_set.contains(target)
478 && other_set.contains(target)
479 {
480 return true;
481 }
482 }
483 }
484 }
485 }
486
487 false
488}
489
490async fn handle_reinforcement(
495 left: &Memory,
496 right: &Memory,
497 perspective: &PerspectiveKey,
498 repo: &MemoryRepository,
499 embeddings: Option<&dyn EmbeddingService>,
500) -> Result<(), AgentError> {
501 let content = format!("Reinforced observation ({}x): {}", 2, left.content.trim());
502
503 let mut cognitive = CognitiveMetadata::new(
504 CognitiveLevel::Derived,
505 perspective.observer.clone(),
506 perspective.subject.clone(),
507 perspective.session_key.clone(),
508 REFLECT_GENERATED_BY,
509 );
510 cognitive.source_memory_ids = vec![left.id, right.id];
511 cognitive.confidence = Some(0.75);
512 cognitive.times_reinforced = 2;
513
514 let metadata = cognitive.merge_into(&serde_json::json!({}));
515 let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
516
517 repo.store_with_lineage(StoreMemoryWithLineageParams {
518 store: StoreMemoryParams {
519 namespace_id: left.namespace_id,
520 content: &content,
521 category: &MemoryCategory::Facts,
522 memory_lane_type: Some(&MemoryLaneType::Cognitive(
523 MemoryLaneCognitiveType::Metamemory,
524 )),
525 labels: &[
526 "reflection".to_string(),
527 "reinforcement".to_string(),
528 "auto".to_string(),
529 ],
530 metadata: &metadata,
531 embedding: embedding.as_deref(),
532 embedding_model: embedding_model.as_deref(),
533 },
534 source_memory_ids: &[left.id, right.id],
535 evidence_role: REINFORCE_EVIDENCE_ROLE,
536 })
537 .await
538 .map_err(|e| AgentError::Storage(e.to_string()))?;
539 increment_cognitive_counter(repo, left.id, "times_reinforced").await?;
540 increment_cognitive_counter(repo, right.id, "times_reinforced").await?;
541
542 debug!(
543 left_id = left.id,
544 right_id = right.id,
545 "Created reinforcement record"
546 );
547
548 Ok(())
549}
550
551async fn handle_contradiction(
556 left: &Memory,
557 right: &Memory,
558 perspective: &PerspectiveKey,
559 repo: &MemoryRepository,
560 embeddings: Option<&dyn EmbeddingService>,
561 cognition: &CognitionConfig,
562) -> Result<i64, AgentError> {
563 let content = format!(
564 "Contradiction: \"{}\" vs \"{}\"",
565 truncate_content(left.content.trim(), 200),
566 truncate_content(right.content.trim(), 200),
567 );
568
569 let mut cognitive = CognitiveMetadata::new(
570 CognitiveLevel::Contradiction,
571 perspective.observer.clone(),
572 perspective.subject.clone(),
573 perspective.session_key.clone(),
574 REFLECT_GENERATED_BY,
575 );
576 cognitive.source_memory_ids = vec![left.id, right.id];
577 cognitive.confidence = Some(0.70);
578 cognitive.times_contradicted = 1;
579 cognitive.generated_by = Some(REFLECT_GENERATED_BY.to_string());
580
581 let metadata = cognitive.merge_into(&serde_json::json!({
582 "contradiction_source_ids": [left.id, right.id],
583 }));
584 let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
585
586 let memory = repo
587 .store_with_lineage(StoreMemoryWithLineageParams {
588 store: StoreMemoryParams {
589 namespace_id: left.namespace_id,
590 content: &content,
591 category: &MemoryCategory::Facts,
592 memory_lane_type: Some(&MemoryLaneType::Cognitive(
593 MemoryLaneCognitiveType::Metamemory,
594 )),
595 labels: &[
596 "reflection".to_string(),
597 "contradiction".to_string(),
598 "auto".to_string(),
599 ],
600 metadata: &metadata,
601 embedding: embedding.as_deref(),
602 embedding_model: embedding_model.as_deref(),
603 },
604 source_memory_ids: &[left.id, right.id],
605 evidence_role: CONTRADICT_EVIDENCE_ROLE,
606 })
607 .await
608 .map_err(|e| AgentError::Storage(e.to_string()))?;
609 if cognition.contradiction_belief_revision_enabled {
613 increment_counter_and_revise_belief(
614 repo,
615 left.id,
616 cognition.contradiction_confidence_penalty,
617 )
618 .await?;
619 increment_counter_and_revise_belief(
620 repo,
621 right.id,
622 cognition.contradiction_confidence_penalty,
623 )
624 .await?;
625 } else {
626 increment_cognitive_counter(repo, left.id, "times_contradicted").await?;
627 increment_cognitive_counter(repo, right.id, "times_contradicted").await?;
628 }
629
630 debug!(
631 left_id = left.id,
632 right_id = right.id,
633 contradiction_id = memory.id,
634 "Created contradiction record"
635 );
636
637 Ok(memory.id)
638}
639
640async fn increment_cognitive_counter(
641 repo: &MemoryRepository,
642 memory_id: i64,
643 counter_key: &str,
644) -> Result<(), AgentError> {
645 let Some(memory) = repo
646 .get_by_id(memory_id)
647 .await
648 .map_err(|e| AgentError::Storage(e.to_string()))?
649 else {
650 return Ok(());
651 };
652
653 let mut cognitive = CognitiveMetadata::from_metadata(&memory.metadata).unwrap_or_else(|| {
657 CognitiveMetadata::new(CognitiveLevel::Raw, "unknown", "unknown", None, "recovery")
658 });
659
660 if cognitive.generated_by.is_none() {
661 cognitive.generated_by = Some(REFLECT_GENERATED_BY.to_string());
662 }
663
664 match counter_key {
665 "times_reinforced" => {
666 cognitive.times_reinforced = cognitive.times_reinforced.saturating_add(1);
667 }
668 "times_contradicted" => {
669 cognitive.times_contradicted = cognitive.times_contradicted.saturating_add(1);
670 }
671 _ => return Ok(()),
672 }
673
674 let merged = cognitive.merge_into(&memory.metadata);
675 repo.update_memory_metadata(memory_id, &merged)
676 .await
677 .map_err(|e| AgentError::Storage(e.to_string()))?;
678 Ok(())
679}
680
681async fn increment_counter_and_revise_belief(
687 repo: &MemoryRepository,
688 memory_id: i64,
689 penalty: f32,
690) -> Result<(), AgentError> {
691 let Some(memory) = repo
692 .get_by_id(memory_id)
693 .await
694 .map_err(|e| AgentError::Storage(e.to_string()))?
695 else {
696 return Ok(());
697 };
698
699 let mut cognitive = match CognitiveMetadata::from_metadata(&memory.metadata) {
700 Some(c) => c,
701 None => return Ok(()),
702 };
703
704 cognitive.times_contradicted = cognitive.times_contradicted.saturating_add(1);
705 cognitive.confidence = Some((cognitive.confidence.unwrap_or(0.75) - penalty).max(0.0));
706 cognitive.last_belief_revision = Some(Utc::now());
707 cognitive.resolution_status = Some("revised".to_string());
708
709 let merged = cognitive.merge_into(&memory.metadata);
710 repo.update_memory_metadata(memory_id, &merged)
711 .await
712 .map_err(|e| AgentError::Storage(e.to_string()))?;
713 Ok(())
714}
715
716async fn synthesize_reinforcement_insights(
717 candidates: &[Memory],
718 reinforcement_pairs: &[(i64, i64)],
719 perspective: &PerspectiveKey,
720 repo: &MemoryRepository,
721 embeddings: Option<&dyn EmbeddingService>,
722) -> Result<Vec<i64>, AgentError> {
723 if reinforcement_pairs.is_empty() {
724 return Ok(Vec::new());
725 }
726
727 let candidate_by_id: HashMap<i64, &Memory> = candidates.iter().map(|m| (m.id, m)).collect();
728 let components = build_reinforcement_components(candidates, reinforcement_pairs);
729 let mut insight_ids = Vec::new();
730
731 for component in components {
732 if component.len() < MIN_INSIGHT_COMPONENT_SIZE {
733 continue;
734 }
735
736 let mut source_ids: Vec<i64> = component.into_iter().collect();
737 source_ids.sort_unstable();
738
739 if find_existing_component_memory(repo, &source_ids, INSIGHT_EVIDENCE_ROLE)
740 .await?
741 .is_some()
742 {
743 continue;
744 }
745
746 let component_memories: Vec<&Memory> = source_ids
747 .iter()
748 .filter_map(|id| candidate_by_id.get(id).copied())
749 .collect();
750 if component_memories.len() < MIN_INSIGHT_COMPONENT_SIZE {
751 continue;
752 }
753
754 let content = build_insight_content(&component_memories);
755 let mut cognitive = CognitiveMetadata::new(
756 CognitiveLevel::Derived,
757 perspective.observer.clone(),
758 perspective.subject.clone(),
759 perspective.session_key.clone(),
760 REFLECT_GENERATED_BY,
761 );
762 cognitive.source_memory_ids = source_ids.clone();
763 cognitive.confidence = Some(insight_confidence(component_memories.len()));
764 cognitive.times_reinforced = component_memories.len() as i64;
765
766 let metadata = cognitive.merge_into(&serde_json::json!({
767 "reflection_kind": "insight",
768 }));
769 let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
770 let memory = repo
771 .store_with_lineage(StoreMemoryWithLineageParams {
772 store: StoreMemoryParams {
773 namespace_id: component_memories[0].namespace_id,
774 content: &content,
775 category: &MemoryCategory::Facts,
776 memory_lane_type: Some(&MemoryLaneType::Priority(
777 MemoryLanePriorityType::Insight,
778 )),
779 labels: &[
780 "reflection".to_string(),
781 "insight".to_string(),
782 "auto".to_string(),
783 ],
784 metadata: &metadata,
785 embedding: embedding.as_deref(),
786 embedding_model: embedding_model.as_deref(),
787 },
788 source_memory_ids: &source_ids,
789 evidence_role: INSIGHT_EVIDENCE_ROLE,
790 })
791 .await
792 .map_err(|e| AgentError::Storage(e.to_string()))?;
793 insight_ids.push(memory.id);
794 }
795
796 Ok(insight_ids)
797}
798
799fn build_reinforcement_components(
800 candidates: &[Memory],
801 reinforcement_pairs: &[(i64, i64)],
802) -> Vec<Vec<i64>> {
803 let mut adjacency: HashMap<i64, Vec<i64>> = HashMap::new();
804 for &(left, right) in reinforcement_pairs {
805 adjacency.entry(left).or_default().push(right);
806 adjacency.entry(right).or_default().push(left);
807 }
808
809 for i in 0..candidates.len() {
810 for j in (i + 1)..candidates.len() {
811 let left = &candidates[i];
812 let right = &candidates[j];
813 if matches!(
814 compare_pair(left, right),
815 Some(ReflectionCase::Contradiction)
816 ) {
817 continue;
818 }
819
820 if word_jaccard(&left.content, &right.content) >= INSIGHT_SIMILARITY_THRESHOLD {
821 adjacency.entry(left.id).or_default().push(right.id);
822 adjacency.entry(right.id).or_default().push(left.id);
823 }
824 }
825 }
826
827 let mut visited = HashSet::new();
828 let mut components = Vec::new();
829 for &node in adjacency.keys() {
830 if !visited.insert(node) {
831 continue;
832 }
833
834 let mut stack = vec![node];
835 let mut component = vec![node];
836 while let Some(current) = stack.pop() {
837 if let Some(neighbors) = adjacency.get(¤t) {
838 for &neighbor in neighbors {
839 if visited.insert(neighbor) {
840 stack.push(neighbor);
841 component.push(neighbor);
842 }
843 }
844 }
845 }
846
847 component.sort_unstable();
848 components.push(component);
849 }
850
851 components
852}
853
854async fn find_existing_component_memory(
855 repo: &MemoryRepository,
856 source_ids: &[i64],
857 evidence_role: &str,
858) -> Result<Option<i64>, AgentError> {
859 let Some(&first_source_id) = source_ids.first() else {
860 return Ok(None);
861 };
862
863 let lineage_by_source = repo
864 .load_lineage_batch(source_ids)
865 .await
866 .map_err(|e| AgentError::Storage(e.to_string()))?;
867 let lineage = lineage_by_source
868 .get(&first_source_id)
869 .cloned()
870 .unwrap_or_default();
871 let candidate_ids: Vec<i64> = lineage
872 .into_iter()
873 .filter(|entry| {
874 entry.source_memory_id == first_source_id && entry.evidence_role == evidence_role
875 })
876 .map(|entry| entry.derived_memory_id)
877 .collect();
878
879 for derived_id in candidate_ids {
880 let matches_all = source_ids.iter().all(|source_id| {
881 lineage_by_source
882 .get(source_id)
883 .into_iter()
884 .flat_map(|entries| entries.iter())
885 .any(|entry| {
886 entry.derived_memory_id == derived_id
887 && entry.source_memory_id == *source_id
888 && entry.evidence_role == evidence_role
889 })
890 });
891
892 if matches_all {
893 return Ok(Some(derived_id));
894 }
895 }
896
897 Ok(None)
898}
899
900fn build_insight_content(memories: &[&Memory]) -> String {
901 let representative = memories
902 .iter()
903 .min_by_key(|memory| memory.id)
904 .map(|memory| truncate_content(memory.content.trim(), MAX_INSIGHT_CONTENT_CHARS))
905 .unwrap_or("repeated observations");
906
907 format!(
908 "Dream insight: repeated evidence indicates {}",
909 representative
910 )
911}
912
913fn insight_confidence(component_size: usize) -> f32 {
914 (0.72 + ((component_size.saturating_sub(MIN_INSIGHT_COMPONENT_SIZE)) as f32 * 0.05)).min(0.92)
915}
916
917fn truncate_content(s: &str, max_chars: usize) -> &str {
918 if s.len() <= max_chars {
919 return s;
920 }
921 let mut end = max_chars;
923 while end > 0 && !s.is_char_boundary(end) {
924 end -= 1;
925 }
926 if let Some(space_pos) = s[..end].rfind(' ') {
928 return &s[..space_pos];
929 }
930 &s[..end]
931}
932
933#[cfg(test)]
938mod tests {
939 use super::*;
940
941 use chrono::Utc;
942 use nexus_core::{Category, MemoryLanePriorityType};
943 use nexus_storage::repository::{NamespaceRepository, StoreMemoryParams};
944 use sqlx::sqlite::SqlitePoolOptions;
945
946 fn test_memory(id: i64, content: &str, metadata: serde_json::Value) -> Memory {
947 Memory {
948 id,
949 namespace_id: 1,
950 content: content.to_string(),
951 category: Category::Facts,
952 memory_lane_type: None,
953 labels: vec![],
954 metadata,
955 similarity_score: None,
956 relevance_score: None,
957 content_embedding: None,
958 embedding_model: None,
959 created_at: Utc::now(),
960 updated_at: None,
961 last_accessed: None,
962 is_active: true,
963 is_archived: false,
964 access_count: 0,
965 }
966 }
967
968 async fn setup_repo() -> (sqlx::SqlitePool, MemoryRepository, i64) {
971 let pool = SqlitePoolOptions::new()
972 .max_connections(1)
973 .connect("sqlite::memory:")
974 .await
975 .unwrap();
976 nexus_storage::migrations::run_migrations(&pool)
977 .await
978 .unwrap();
979 let namespace_repo = NamespaceRepository::new(pool.clone());
980 let namespace = namespace_repo
981 .get_or_create("reflect-test", "reflect-test")
982 .await
983 .unwrap();
984 let repo = MemoryRepository::new(pool.clone());
985 (pool, repo, namespace.id)
986 }
987
988 fn explicit_metadata(observer: &str) -> serde_json::Value {
989 let cognitive = CognitiveMetadata::new(
990 CognitiveLevel::Explicit,
991 observer,
992 observer,
993 None,
994 "derive_service",
995 );
996 cognitive.merge_into(&serde_json::json!({}))
997 }
998
999 fn derived_metadata(observer: &str) -> serde_json::Value {
1000 let cognitive = CognitiveMetadata::new(
1001 CognitiveLevel::Derived,
1002 observer,
1003 observer,
1004 None,
1005 "derive_service",
1006 );
1007 cognitive.merge_into(&serde_json::json!({}))
1008 }
1009
1010 fn raw_metadata() -> serde_json::Value {
1011 let cognitive = CognitiveMetadata::new(
1012 CognitiveLevel::Raw,
1013 "claude-code",
1014 "claude-code",
1015 None,
1016 "ingest_service",
1017 );
1018 cognitive.merge_into(&serde_json::json!({}))
1019 }
1020
1021 async fn store_memory(
1022 repo: &MemoryRepository,
1023 namespace_id: i64,
1024 content: &str,
1025 metadata: &serde_json::Value,
1026 ) -> Memory {
1027 repo.store(StoreMemoryParams {
1028 namespace_id,
1029 content,
1030 category: &MemoryCategory::Facts,
1031 memory_lane_type: Some(&MemoryLaneType::Priority(MemoryLanePriorityType::Decision)),
1032 labels: &["test".to_string()],
1033 metadata,
1034 embedding: None,
1035 embedding_model: None,
1036 })
1037 .await
1038 .unwrap()
1039 }
1040
1041 #[test]
1044 fn test_word_jaccard_identical() {
1045 assert!((word_jaccard("hello world", "hello world") - 1.0).abs() < f32::EPSILON);
1046 }
1047
1048 #[test]
1049 fn test_word_jaccard_disjoint() {
1050 assert!((word_jaccard("alpha beta", "gamma delta")).abs() < f32::EPSILON);
1051 }
1052
1053 #[test]
1054 fn test_word_jaccard_partial() {
1055 let j = word_jaccard(
1056 "the query service handles search",
1057 "the query service handles pagination",
1058 );
1059 assert!(j > 0.5, "expected partial overlap, got {}", j);
1060 }
1061
1062 #[test]
1063 fn test_word_jaccard_empty_strings() {
1064 assert!((word_jaccard("", "") - 1.0).abs() < f32::EPSILON);
1065 }
1066
1067 #[test]
1068 fn test_word_jaccard_one_empty() {
1069 assert!((word_jaccard("hello", "")).abs() < f32::EPSILON);
1070 }
1071
1072 #[test]
1073 fn test_compare_pair_similar_content_reinforces() {
1074 let left = test_memory(
1075 1,
1076 "The query service handles search requests",
1077 explicit_metadata("claude-code"),
1078 );
1079 let right = test_memory(
1080 2,
1081 "The query service handles search requests efficiently",
1082 explicit_metadata("claude-code"),
1083 );
1084
1085 assert_eq!(
1086 compare_pair(&left, &right),
1087 Some(ReflectionCase::Reinforcement)
1088 );
1089 }
1090
1091 #[test]
1092 fn test_compare_pair_contradiction_pattern() {
1093 let left = test_memory(
1094 1,
1095 "The cache system is enabled and improves performance",
1096 explicit_metadata("claude-code"),
1097 );
1098 let right = test_memory(
1099 2,
1100 "The cache system is not enabled and degrades performance",
1101 explicit_metadata("claude-code"),
1102 );
1103
1104 assert_eq!(
1105 compare_pair(&left, &right),
1106 Some(ReflectionCase::Contradiction)
1107 );
1108 }
1109
1110 #[test]
1111 fn test_compare_pair_unrelated() {
1112 let left = test_memory(
1113 1,
1114 "Fixed pagination bug in search endpoint",
1115 explicit_metadata("claude-code"),
1116 );
1117 let right = test_memory(
1118 2,
1119 "Updated deployment configuration for staging",
1120 explicit_metadata("claude-code"),
1121 );
1122
1123 assert_eq!(compare_pair(&left, &right), None);
1124 }
1125
1126 #[test]
1127 fn test_has_negation_contradiction_detects_negation() {
1128 assert!(has_negation_contradiction(
1129 "the feature is not working correctly",
1130 "the feature is working correctly"
1131 ));
1132 }
1133
1134 #[test]
1135 fn test_has_negation_contradiction_no_negation() {
1136 assert!(!has_negation_contradiction(
1137 "the feature works well",
1138 "the feature is fast"
1139 ));
1140 }
1141
1142 #[tokio::test]
1145 async fn test_reflect_cycle_empty_namespace() {
1146 let (_pool, repo, namespace_id) = setup_repo().await;
1147 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1148
1149 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1150 assert_eq!(result.memories_scanned, 0);
1151 assert_eq!(result.pairs_compared, 0);
1152 assert_eq!(result.reinforcements, 0);
1153 assert_eq!(result.insights_created, 0);
1154 assert_eq!(result.contradictions_created, 0);
1155 }
1156
1157 #[tokio::test]
1158 async fn test_reflect_cycle_skips_raw_memories() {
1159 let (_pool, repo, namespace_id) = setup_repo().await;
1160
1161 store_memory(&repo, namespace_id, "raw noise event", &raw_metadata()).await;
1162
1163 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1164 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1165
1166 assert_eq!(result.memories_scanned, 0);
1167 }
1168
1169 #[tokio::test]
1170 async fn test_reflect_cycle_detects_reinforcement() {
1171 let (_pool, repo, namespace_id) = setup_repo().await;
1172
1173 let left = store_memory(
1174 &repo,
1175 namespace_id,
1176 "The query service handles search requests",
1177 &explicit_metadata("claude-code"),
1178 )
1179 .await;
1180 let right = store_memory(
1181 &repo,
1182 namespace_id,
1183 "The query service handles search requests efficiently",
1184 &explicit_metadata("claude-code"),
1185 )
1186 .await;
1187
1188 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1189 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1190
1191 assert_eq!(result.memories_scanned, 2);
1192 assert!(
1193 result.reinforcements >= 1,
1194 "expected at least 1 reinforcement"
1195 );
1196 assert_eq!(result.contradictions_created, 0);
1197
1198 let left = repo.get_by_id(left.id).await.unwrap().unwrap();
1199 let right = repo.get_by_id(right.id).await.unwrap().unwrap();
1200 let left_cognitive = CognitiveMetadata::from_metadata(&left.metadata).unwrap();
1201 let right_cognitive = CognitiveMetadata::from_metadata(&right.metadata).unwrap();
1202 assert_eq!(left_cognitive.times_reinforced, 1);
1203 assert_eq!(right_cognitive.times_reinforced, 1);
1204 }
1205
1206 #[tokio::test]
1207 async fn test_reflect_cycle_detects_contradiction() {
1208 let (_pool, repo, namespace_id) = setup_repo().await;
1209
1210 let left = store_memory(
1211 &repo,
1212 namespace_id,
1213 "The cache system is enabled and improves performance",
1214 &explicit_metadata("claude-code"),
1215 )
1216 .await;
1217 let right = store_memory(
1218 &repo,
1219 namespace_id,
1220 "The cache system is not enabled and degrades performance",
1221 &explicit_metadata("claude-code"),
1222 )
1223 .await;
1224
1225 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1226 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1227
1228 assert_eq!(result.memories_scanned, 2);
1229 assert_eq!(result.contradictions_created, 1);
1230 assert_eq!(result.contradiction_ids.len(), 1);
1231
1232 let left = repo.get_by_id(left.id).await.unwrap().unwrap();
1233 let right = repo.get_by_id(right.id).await.unwrap().unwrap();
1234 let left_cognitive = CognitiveMetadata::from_metadata(&left.metadata).unwrap();
1235 let right_cognitive = CognitiveMetadata::from_metadata(&right.metadata).unwrap();
1236 assert_eq!(left_cognitive.times_contradicted, 1);
1237 assert_eq!(right_cognitive.times_contradicted, 1);
1238 }
1239
1240 #[tokio::test]
1241 async fn test_reflect_cycle_is_idempotent() {
1242 let (_pool, repo, namespace_id) = setup_repo().await;
1243
1244 store_memory(
1245 &repo,
1246 namespace_id,
1247 "The query service handles search requests",
1248 &explicit_metadata("claude-code"),
1249 )
1250 .await;
1251 store_memory(
1252 &repo,
1253 namespace_id,
1254 "The query service handles search requests efficiently",
1255 &explicit_metadata("claude-code"),
1256 )
1257 .await;
1258
1259 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1260
1261 let result1 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1263 let first_reinforcements = result1.reinforcements;
1264
1265 let result2 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1267 assert_eq!(
1268 result2.reinforcements, 0,
1269 "second pass should not create duplicate reinforcements"
1270 );
1271 assert!(first_reinforcements >= 1);
1273 }
1274
1275 #[tokio::test]
1276 async fn test_reinforcement_creates_evidence_links() {
1277 let (_pool, repo, namespace_id) = setup_repo().await;
1278
1279 let m1 = store_memory(
1280 &repo,
1281 namespace_id,
1282 "The query service handles search requests",
1283 &explicit_metadata("claude-code"),
1284 )
1285 .await;
1286 let m2 = store_memory(
1287 &repo,
1288 namespace_id,
1289 "The query service handles search requests efficiently",
1290 &explicit_metadata("claude-code"),
1291 )
1292 .await;
1293
1294 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1295 service.reflect_cycle(namespace_id, &repo).await.unwrap();
1296
1297 let lineage1 = repo.load_lineage(m1.id).await.unwrap();
1300 let reinforcement_ids: Vec<i64> = lineage1
1301 .iter()
1302 .filter(|e| e.evidence_role == REINFORCE_EVIDENCE_ROLE)
1303 .map(|e| e.derived_memory_id)
1304 .collect();
1305
1306 let lineage2 = repo.load_lineage(m2.id).await.unwrap();
1307 let shared: Vec<i64> = lineage2
1308 .iter()
1309 .filter(|e| {
1310 e.evidence_role == REINFORCE_EVIDENCE_ROLE
1311 && reinforcement_ids.contains(&e.derived_memory_id)
1312 })
1313 .map(|e| e.derived_memory_id)
1314 .collect();
1315
1316 assert!(
1317 !shared.is_empty(),
1318 "expected shared reinforcement memory linking both sources"
1319 );
1320 }
1321
1322 #[tokio::test]
1323 async fn test_contradiction_stores_with_correct_metadata() {
1324 let (_pool, repo, namespace_id) = setup_repo().await;
1325
1326 store_memory(
1327 &repo,
1328 namespace_id,
1329 "The cache system is enabled and improves performance",
1330 &explicit_metadata("claude-code"),
1331 )
1332 .await;
1333 store_memory(
1334 &repo,
1335 namespace_id,
1336 "The cache system is not enabled and degrades performance",
1337 &explicit_metadata("claude-code"),
1338 )
1339 .await;
1340
1341 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1342 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1343 assert_eq!(result.contradiction_ids.len(), 1);
1344
1345 let contradiction_id = result.contradiction_ids[0];
1347 let memories = repo
1348 .list_filtered(
1349 namespace_id,
1350 ListMemoryFilters {
1351 category: None,
1352 since: None,
1353 until: None,
1354 content_like: Some("Contradiction"),
1355 include_raw: false,
1356 limit: 10,
1357 offset: 0,
1358 },
1359 )
1360 .await
1361 .unwrap();
1362
1363 let contradiction = memories
1364 .iter()
1365 .find(|m| m.id == contradiction_id)
1366 .expect("contradiction memory should be retrievable");
1367
1368 let cognitive = CognitiveMetadata::from_metadata(&contradiction.metadata)
1369 .expect("contradiction memory should have cognitive metadata");
1370 assert_eq!(cognitive.level, CognitiveLevel::Contradiction);
1371 assert_eq!(
1372 cognitive.generated_by,
1373 Some(REFLECT_GENERATED_BY.to_string())
1374 );
1375 assert_eq!(cognitive.source_memory_ids.len(), 2);
1376 assert!(cognitive.confidence.is_some());
1377 assert!(cognitive.confidence.unwrap() > 0.0);
1378
1379 let lineage = repo.load_lineage(contradiction_id).await.unwrap();
1381 assert!(
1382 lineage
1383 .iter()
1384 .any(|e| e.evidence_role == CONTRADICT_EVIDENCE_ROLE),
1385 "contradiction memory should have contradicts evidence"
1386 );
1387 }
1388
1389 #[tokio::test]
1390 async fn test_reflect_cycle_handles_derived_level() {
1391 let (_pool, repo, namespace_id) = setup_repo().await;
1392
1393 store_memory(
1394 &repo,
1395 namespace_id,
1396 "The query service handles search requests",
1397 &derived_metadata("claude-code"),
1398 )
1399 .await;
1400 store_memory(
1401 &repo,
1402 namespace_id,
1403 "The query service handles search requests efficiently",
1404 &derived_metadata("claude-code"),
1405 )
1406 .await;
1407
1408 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1409 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1410
1411 assert_eq!(result.memories_scanned, 2);
1412 assert!(result.reinforcements >= 1);
1413 }
1414
1415 #[tokio::test]
1416 async fn test_reflect_cycle_creates_higher_order_insight() {
1417 let (_pool, repo, namespace_id) = setup_repo().await;
1418
1419 for content in [
1420 "The query service handles search requests",
1421 "The query service handles search requests efficiently",
1422 "The query service handles search requests reliably",
1423 ] {
1424 store_memory(
1425 &repo,
1426 namespace_id,
1427 content,
1428 &explicit_metadata("claude-code"),
1429 )
1430 .await;
1431 }
1432
1433 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1434 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1435
1436 assert_eq!(result.insights_created, 1);
1437 assert_eq!(result.insight_ids.len(), 1);
1438
1439 let insight = repo
1440 .get_by_id(result.insight_ids[0])
1441 .await
1442 .unwrap()
1443 .unwrap();
1444 assert!(insight.content.starts_with("Dream insight:"));
1445 assert!(insight.labels.iter().any(|label| label == "insight"));
1446 let cognitive = CognitiveMetadata::from_metadata(&insight.metadata).unwrap();
1447 assert_eq!(cognitive.level, CognitiveLevel::Derived);
1448 assert_eq!(cognitive.source_memory_ids.len(), 3);
1449 assert_eq!(cognitive.times_reinforced, 3);
1450
1451 let lineage = repo.load_lineage(insight.id).await.unwrap();
1452 let evidence_count = lineage
1453 .iter()
1454 .filter(|entry| entry.evidence_role == INSIGHT_EVIDENCE_ROLE)
1455 .count();
1456 assert_eq!(evidence_count, 3);
1457 }
1458
1459 #[tokio::test]
1460 async fn test_reflect_cycle_contradiction_idempotent() {
1461 let (_pool, repo, namespace_id) = setup_repo().await;
1462
1463 store_memory(
1464 &repo,
1465 namespace_id,
1466 "The cache system is enabled and improves performance",
1467 &explicit_metadata("claude-code"),
1468 )
1469 .await;
1470 store_memory(
1471 &repo,
1472 namespace_id,
1473 "The cache system is not enabled and degrades performance",
1474 &explicit_metadata("claude-code"),
1475 )
1476 .await;
1477
1478 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1479
1480 let result1 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1481 assert_eq!(result1.contradictions_created, 1);
1482
1483 let result2 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1484 assert_eq!(
1485 result2.contradictions_created, 0,
1486 "second pass should not create duplicate contradictions"
1487 );
1488 }
1489
1490 #[tokio::test]
1491 async fn test_reflect_cycle_insight_idempotent() {
1492 let (_pool, repo, namespace_id) = setup_repo().await;
1493
1494 for content in [
1495 "The query service handles search requests",
1496 "The query service handles search requests efficiently",
1497 "The query service handles search requests reliably",
1498 ] {
1499 store_memory(
1500 &repo,
1501 namespace_id,
1502 content,
1503 &explicit_metadata("claude-code"),
1504 )
1505 .await;
1506 }
1507
1508 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1509 let first = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1510 let second = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1511
1512 assert_eq!(first.insights_created, 1);
1513 assert_eq!(second.insights_created, 0);
1514
1515 let insights = repo
1516 .list_filtered(
1517 namespace_id,
1518 ListMemoryFilters {
1519 category: None,
1520 since: None,
1521 until: None,
1522 content_like: Some("Dream insight:"),
1523 include_raw: false,
1524 limit: 10,
1525 offset: 0,
1526 },
1527 )
1528 .await
1529 .unwrap();
1530 assert_eq!(insights.len(), 1);
1531 }
1532
1533 #[tokio::test]
1534 async fn test_contradiction_gets_embedding_when_service_provided() {
1535 let (_pool, repo, namespace_id) = setup_repo().await;
1536
1537 store_memory(
1538 &repo,
1539 namespace_id,
1540 "The cache system is enabled and improves performance",
1541 &explicit_metadata("claude-code"),
1542 )
1543 .await;
1544 store_memory(
1545 &repo,
1546 namespace_id,
1547 "The cache system is not enabled and degrades performance",
1548 &explicit_metadata("claude-code"),
1549 )
1550 .await;
1551
1552 let mock_embed = nexus_embeddings::MockEmbeddingService::new();
1553 let service = ReflectService::new(
1554 AgentConfig::default(),
1555 CognitionConfig::default(),
1556 Some(Arc::new(mock_embed)),
1557 );
1558 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1559
1560 assert_eq!(result.contradiction_ids.len(), 1);
1561
1562 let contradiction = repo
1563 .get_by_id(result.contradiction_ids[0])
1564 .await
1565 .unwrap()
1566 .unwrap();
1567 assert!(
1568 contradiction.content_embedding.is_some(),
1569 "contradiction memory should have an embedding when service is provided"
1570 );
1571 assert_eq!(
1572 contradiction.content_embedding.as_ref().unwrap().len(),
1573 384,
1574 "embedding dimension should be 384"
1575 );
1576 }
1577
1578 #[tokio::test]
1579 async fn test_insight_gets_embedding_when_service_provided() {
1580 let (_pool, repo, namespace_id) = setup_repo().await;
1581
1582 for content in [
1583 "The query service handles search requests",
1584 "The query service handles search requests efficiently",
1585 "The query service handles search requests reliably",
1586 ] {
1587 store_memory(
1588 &repo,
1589 namespace_id,
1590 content,
1591 &explicit_metadata("claude-code"),
1592 )
1593 .await;
1594 }
1595
1596 let mock_embed = nexus_embeddings::MockEmbeddingService::new();
1597 let service = ReflectService::new(
1598 AgentConfig::default(),
1599 CognitionConfig::default(),
1600 Some(Arc::new(mock_embed)),
1601 );
1602 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1603
1604 assert_eq!(result.insights_created, 1);
1605
1606 let insight = repo
1607 .get_by_id(result.insight_ids[0])
1608 .await
1609 .unwrap()
1610 .unwrap();
1611 assert!(
1612 insight.content_embedding.is_some(),
1613 "insight memory should have an embedding when service is provided"
1614 );
1615 assert_eq!(
1616 insight.content_embedding.as_ref().unwrap().len(),
1617 384,
1618 "embedding dimension should be 384"
1619 );
1620 }
1621
1622 #[tokio::test]
1623 async fn test_reflect_stores_without_embedding_when_service_absent() {
1624 let (_pool, repo, namespace_id) = setup_repo().await;
1625
1626 store_memory(
1627 &repo,
1628 namespace_id,
1629 "The cache system is enabled and improves performance",
1630 &explicit_metadata("claude-code"),
1631 )
1632 .await;
1633 store_memory(
1634 &repo,
1635 namespace_id,
1636 "The cache system is not enabled and degrades performance",
1637 &explicit_metadata("claude-code"),
1638 )
1639 .await;
1640
1641 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1642 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1643 assert_eq!(result.contradiction_ids.len(), 1);
1644
1645 let contradiction = repo
1646 .get_by_id(result.contradiction_ids[0])
1647 .await
1648 .unwrap()
1649 .unwrap();
1650 assert!(
1651 contradiction.content_embedding.is_none(),
1652 "contradiction memory should NOT have embedding when no service provided"
1653 );
1654 }
1655
1656 #[tokio::test]
1657 async fn test_reflect_cycle_does_not_cross_perspectives() {
1658 let (_pool, repo, namespace_id) = setup_repo().await;
1659
1660 store_memory(
1661 &repo,
1662 namespace_id,
1663 "The query service handles search requests",
1664 &explicit_metadata("claude-code"),
1665 )
1666 .await;
1667 store_memory(
1668 &repo,
1669 namespace_id,
1670 "The query service handles search requests efficiently",
1671 &explicit_metadata("codex"),
1672 )
1673 .await;
1674
1675 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1676 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1677
1678 assert_eq!(result.memories_scanned, 2);
1679 assert_eq!(result.pairs_compared, 0);
1680 assert_eq!(result.reinforcements, 0);
1681 assert_eq!(result.contradictions_created, 0);
1682 }
1683
1684 #[tokio::test]
1687 async fn test_belief_revision_reduces_confidence_on_contradiction() {
1688 let (_pool, repo, namespace_id) = setup_repo().await;
1689
1690 let left = store_memory(
1691 &repo,
1692 namespace_id,
1693 "The cache system is enabled and improves performance",
1694 &explicit_metadata("claude-code"),
1695 )
1696 .await;
1697 let right = store_memory(
1698 &repo,
1699 namespace_id,
1700 "The cache system is not enabled and degrades performance",
1701 &explicit_metadata("claude-code"),
1702 )
1703 .await;
1704
1705 let left_before = repo.get_by_id(left.id).await.unwrap().unwrap();
1707 let right_before = repo.get_by_id(right.id).await.unwrap().unwrap();
1708 let left_conf_before = CognitiveMetadata::from_metadata(&left_before.metadata)
1709 .unwrap()
1710 .confidence;
1711 let right_conf_before = CognitiveMetadata::from_metadata(&right_before.metadata)
1712 .unwrap()
1713 .confidence;
1714
1715 let service = ReflectService::new(
1716 AgentConfig::default(),
1717 CognitionConfig {
1718 contradiction_belief_revision_enabled: true,
1719 contradiction_confidence_penalty: 0.15,
1720 ..CognitionConfig::default()
1721 },
1722 None,
1723 );
1724 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1725
1726 assert_eq!(result.contradictions_created, 1);
1727
1728 let left_after = repo.get_by_id(left.id).await.unwrap().unwrap();
1729 let right_after = repo.get_by_id(right.id).await.unwrap().unwrap();
1730 let left_cognitive = CognitiveMetadata::from_metadata(&left_after.metadata).unwrap();
1731 let right_cognitive = CognitiveMetadata::from_metadata(&right_after.metadata).unwrap();
1732
1733 assert!(
1735 left_cognitive.confidence.unwrap_or(0.75) < left_conf_before.unwrap_or(0.75),
1736 "left confidence should decrease after belief revision"
1737 );
1738 assert!(
1739 right_cognitive.confidence.unwrap_or(0.75) < right_conf_before.unwrap_or(0.75),
1740 "right confidence should decrease after belief revision"
1741 );
1742 assert!(left_cognitive.last_belief_revision.is_some());
1744 assert!(right_cognitive.last_belief_revision.is_some());
1745 assert_eq!(left_cognitive.resolution_status.as_deref(), Some("revised"));
1747 assert_eq!(
1748 right_cognitive.resolution_status.as_deref(),
1749 Some("revised")
1750 );
1751 }
1752
1753 #[tokio::test]
1754 async fn test_belief_revision_disabled_does_not_modify_confidence() {
1755 let (_pool, repo, namespace_id) = setup_repo().await;
1756
1757 store_memory(
1758 &repo,
1759 namespace_id,
1760 "The cache system is enabled",
1761 &explicit_metadata("claude-code"),
1762 )
1763 .await;
1764 store_memory(
1765 &repo,
1766 namespace_id,
1767 "The cache system is not enabled",
1768 &explicit_metadata("claude-code"),
1769 )
1770 .await;
1771
1772 let service = ReflectService::new(
1773 AgentConfig::default(),
1774 CognitionConfig {
1775 contradiction_belief_revision_enabled: false,
1776 ..CognitionConfig::default()
1777 },
1778 None,
1779 );
1780 let _result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1781
1782 let memories = repo
1784 .list_filtered(
1785 namespace_id,
1786 ListMemoryFilters {
1787 category: None,
1788 since: None,
1789 until: None,
1790 content_like: None,
1791 include_raw: false,
1792 limit: 10,
1793 offset: 0,
1794 },
1795 )
1796 .await
1797 .unwrap();
1798
1799 for memory in memories {
1800 if let Some(cognitive) = CognitiveMetadata::from_metadata(&memory.metadata) {
1801 assert!(
1802 cognitive.last_belief_revision.is_none(),
1803 "no belief revision should occur when disabled"
1804 );
1805 assert!(
1806 cognitive.resolution_status.is_none(),
1807 "no resolution status when belief revision disabled"
1808 );
1809 }
1810 }
1811 }
1812}