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
580 let metadata = cognitive.merge_into(&serde_json::json!({
581 "contradiction_source_ids": [left.id, right.id],
582 }));
583 let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
584
585 let memory = repo
586 .store_with_lineage(StoreMemoryWithLineageParams {
587 store: StoreMemoryParams {
588 namespace_id: left.namespace_id,
589 content: &content,
590 category: &MemoryCategory::Facts,
591 memory_lane_type: Some(&MemoryLaneType::Cognitive(
592 MemoryLaneCognitiveType::Metamemory,
593 )),
594 labels: &[
595 "reflection".to_string(),
596 "contradiction".to_string(),
597 "auto".to_string(),
598 ],
599 metadata: &metadata,
600 embedding: embedding.as_deref(),
601 embedding_model: embedding_model.as_deref(),
602 },
603 source_memory_ids: &[left.id, right.id],
604 evidence_role: CONTRADICT_EVIDENCE_ROLE,
605 })
606 .await
607 .map_err(|e| AgentError::Storage(e.to_string()))?;
608 if cognition.contradiction_belief_revision_enabled {
612 increment_counter_and_revise_belief(
613 repo,
614 left.id,
615 cognition.contradiction_confidence_penalty,
616 )
617 .await?;
618 increment_counter_and_revise_belief(
619 repo,
620 right.id,
621 cognition.contradiction_confidence_penalty,
622 )
623 .await?;
624 } else {
625 increment_cognitive_counter(repo, left.id, "times_contradicted").await?;
626 increment_cognitive_counter(repo, right.id, "times_contradicted").await?;
627 }
628
629 debug!(
630 left_id = left.id,
631 right_id = right.id,
632 contradiction_id = memory.id,
633 "Created contradiction record"
634 );
635
636 Ok(memory.id)
637}
638
639async fn increment_cognitive_counter(
640 repo: &MemoryRepository,
641 memory_id: i64,
642 counter_key: &str,
643) -> Result<(), AgentError> {
644 let Some(memory) = repo
645 .get_by_id(memory_id)
646 .await
647 .map_err(|e| AgentError::Storage(e.to_string()))?
648 else {
649 return Ok(());
650 };
651
652 let mut cognitive =
656 CognitiveMetadata::from_metadata(&memory.metadata).unwrap_or_else(|| CognitiveMetadata {
657 level: CognitiveLevel::Raw,
658 observer: "unknown".to_string(),
659 subject: "unknown".to_string(),
660 session_key: None,
661 source_memory_ids: Vec::new(),
662 confidence: None,
663 times_reinforced: 0,
664 times_contradicted: 0,
665 generated_by: REFLECT_GENERATED_BY.to_string(),
666 derived_at: None,
667 last_belief_revision: None,
668 resolution_status: None,
669 });
670
671 match counter_key {
672 "times_reinforced" => {
673 cognitive.times_reinforced = cognitive.times_reinforced.saturating_add(1);
674 }
675 "times_contradicted" => {
676 cognitive.times_contradicted = cognitive.times_contradicted.saturating_add(1);
677 }
678 _ => return Ok(()),
679 }
680
681 let merged = cognitive.merge_into(&memory.metadata);
682 repo.update_memory_metadata(memory_id, &merged)
683 .await
684 .map_err(|e| AgentError::Storage(e.to_string()))?;
685 Ok(())
686}
687
688async fn increment_counter_and_revise_belief(
694 repo: &MemoryRepository,
695 memory_id: i64,
696 penalty: f32,
697) -> Result<(), AgentError> {
698 let Some(memory) = repo
699 .get_by_id(memory_id)
700 .await
701 .map_err(|e| AgentError::Storage(e.to_string()))?
702 else {
703 return Ok(());
704 };
705
706 let mut cognitive = match CognitiveMetadata::from_metadata(&memory.metadata) {
707 Some(c) => c,
708 None => return Ok(()),
709 };
710
711 cognitive.times_contradicted = cognitive.times_contradicted.saturating_add(1);
712 cognitive.confidence = Some((cognitive.confidence.unwrap_or(0.75) - penalty).max(0.0));
713 cognitive.last_belief_revision = Some(Utc::now());
714 cognitive.resolution_status = Some("revised".to_string());
715
716 let merged = cognitive.merge_into(&memory.metadata);
717 repo.update_memory_metadata(memory_id, &merged)
718 .await
719 .map_err(|e| AgentError::Storage(e.to_string()))?;
720 Ok(())
721}
722
723async fn synthesize_reinforcement_insights(
724 candidates: &[Memory],
725 reinforcement_pairs: &[(i64, i64)],
726 perspective: &PerspectiveKey,
727 repo: &MemoryRepository,
728 embeddings: Option<&dyn EmbeddingService>,
729) -> Result<Vec<i64>, AgentError> {
730 if reinforcement_pairs.is_empty() {
731 return Ok(Vec::new());
732 }
733
734 let candidate_by_id: HashMap<i64, &Memory> = candidates.iter().map(|m| (m.id, m)).collect();
735 let components = build_reinforcement_components(candidates, reinforcement_pairs);
736 let mut insight_ids = Vec::new();
737
738 for component in components {
739 if component.len() < MIN_INSIGHT_COMPONENT_SIZE {
740 continue;
741 }
742
743 let mut source_ids: Vec<i64> = component.into_iter().collect();
744 source_ids.sort_unstable();
745
746 if find_existing_component_memory(repo, &source_ids, INSIGHT_EVIDENCE_ROLE)
747 .await?
748 .is_some()
749 {
750 continue;
751 }
752
753 let component_memories: Vec<&Memory> = source_ids
754 .iter()
755 .filter_map(|id| candidate_by_id.get(id).copied())
756 .collect();
757 if component_memories.len() < MIN_INSIGHT_COMPONENT_SIZE {
758 continue;
759 }
760
761 let content = build_insight_content(&component_memories);
762 let mut cognitive = CognitiveMetadata::new(
763 CognitiveLevel::Derived,
764 perspective.observer.clone(),
765 perspective.subject.clone(),
766 perspective.session_key.clone(),
767 REFLECT_GENERATED_BY,
768 );
769 cognitive.source_memory_ids = source_ids.clone();
770 cognitive.confidence = Some(insight_confidence(component_memories.len()));
771 cognitive.times_reinforced = component_memories.len() as i64;
772
773 let metadata = cognitive.merge_into(&serde_json::json!({
774 "reflection_kind": "insight",
775 }));
776 let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
777 let memory = repo
778 .store_with_lineage(StoreMemoryWithLineageParams {
779 store: StoreMemoryParams {
780 namespace_id: component_memories[0].namespace_id,
781 content: &content,
782 category: &MemoryCategory::Facts,
783 memory_lane_type: Some(&MemoryLaneType::Priority(
784 MemoryLanePriorityType::Insight,
785 )),
786 labels: &[
787 "reflection".to_string(),
788 "insight".to_string(),
789 "auto".to_string(),
790 ],
791 metadata: &metadata,
792 embedding: embedding.as_deref(),
793 embedding_model: embedding_model.as_deref(),
794 },
795 source_memory_ids: &source_ids,
796 evidence_role: INSIGHT_EVIDENCE_ROLE,
797 })
798 .await
799 .map_err(|e| AgentError::Storage(e.to_string()))?;
800 insight_ids.push(memory.id);
801 }
802
803 Ok(insight_ids)
804}
805
806fn build_reinforcement_components(
807 candidates: &[Memory],
808 reinforcement_pairs: &[(i64, i64)],
809) -> Vec<Vec<i64>> {
810 let mut adjacency: HashMap<i64, Vec<i64>> = HashMap::new();
811 for &(left, right) in reinforcement_pairs {
812 adjacency.entry(left).or_default().push(right);
813 adjacency.entry(right).or_default().push(left);
814 }
815
816 for i in 0..candidates.len() {
817 for j in (i + 1)..candidates.len() {
818 let left = &candidates[i];
819 let right = &candidates[j];
820 if matches!(
821 compare_pair(left, right),
822 Some(ReflectionCase::Contradiction)
823 ) {
824 continue;
825 }
826
827 if word_jaccard(&left.content, &right.content) >= INSIGHT_SIMILARITY_THRESHOLD {
828 adjacency.entry(left.id).or_default().push(right.id);
829 adjacency.entry(right.id).or_default().push(left.id);
830 }
831 }
832 }
833
834 let mut visited = HashSet::new();
835 let mut components = Vec::new();
836 for &node in adjacency.keys() {
837 if !visited.insert(node) {
838 continue;
839 }
840
841 let mut stack = vec![node];
842 let mut component = vec![node];
843 while let Some(current) = stack.pop() {
844 if let Some(neighbors) = adjacency.get(¤t) {
845 for &neighbor in neighbors {
846 if visited.insert(neighbor) {
847 stack.push(neighbor);
848 component.push(neighbor);
849 }
850 }
851 }
852 }
853
854 component.sort_unstable();
855 components.push(component);
856 }
857
858 components
859}
860
861async fn find_existing_component_memory(
862 repo: &MemoryRepository,
863 source_ids: &[i64],
864 evidence_role: &str,
865) -> Result<Option<i64>, AgentError> {
866 let Some(&first_source_id) = source_ids.first() else {
867 return Ok(None);
868 };
869
870 let lineage_by_source = repo
871 .load_lineage_batch(source_ids)
872 .await
873 .map_err(|e| AgentError::Storage(e.to_string()))?;
874 let lineage = lineage_by_source
875 .get(&first_source_id)
876 .cloned()
877 .unwrap_or_default();
878 let candidate_ids: Vec<i64> = lineage
879 .into_iter()
880 .filter(|entry| {
881 entry.source_memory_id == first_source_id && entry.evidence_role == evidence_role
882 })
883 .map(|entry| entry.derived_memory_id)
884 .collect();
885
886 for derived_id in candidate_ids {
887 let matches_all = source_ids.iter().all(|source_id| {
888 lineage_by_source
889 .get(source_id)
890 .into_iter()
891 .flat_map(|entries| entries.iter())
892 .any(|entry| {
893 entry.derived_memory_id == derived_id
894 && entry.source_memory_id == *source_id
895 && entry.evidence_role == evidence_role
896 })
897 });
898
899 if matches_all {
900 return Ok(Some(derived_id));
901 }
902 }
903
904 Ok(None)
905}
906
907fn build_insight_content(memories: &[&Memory]) -> String {
908 let representative = memories
909 .iter()
910 .min_by_key(|memory| memory.id)
911 .map(|memory| truncate_content(memory.content.trim(), MAX_INSIGHT_CONTENT_CHARS))
912 .unwrap_or("repeated observations");
913
914 format!(
915 "Dream insight: repeated evidence indicates {}",
916 representative
917 )
918}
919
920fn insight_confidence(component_size: usize) -> f32 {
921 (0.72 + ((component_size.saturating_sub(MIN_INSIGHT_COMPONENT_SIZE)) as f32 * 0.05)).min(0.92)
922}
923
924fn truncate_content(s: &str, max_chars: usize) -> &str {
925 if s.len() <= max_chars {
926 return s;
927 }
928 let mut end = max_chars;
930 while end > 0 && !s.is_char_boundary(end) {
931 end -= 1;
932 }
933 if let Some(space_pos) = s[..end].rfind(' ') {
935 return &s[..space_pos];
936 }
937 &s[..end]
938}
939
940#[cfg(test)]
945mod tests {
946 use super::*;
947
948 use chrono::Utc;
949 use nexus_core::{Category, MemoryLanePriorityType};
950 use nexus_storage::repository::{NamespaceRepository, StoreMemoryParams};
951 use sqlx::sqlite::SqlitePoolOptions;
952
953 fn test_memory(id: i64, content: &str, metadata: serde_json::Value) -> Memory {
954 Memory {
955 id,
956 namespace_id: 1,
957 content: content.to_string(),
958 category: Category::Facts,
959 memory_lane_type: None,
960 labels: vec![],
961 metadata,
962 similarity_score: None,
963 relevance_score: None,
964 content_embedding: None,
965 embedding_model: None,
966 created_at: Utc::now(),
967 updated_at: None,
968 last_accessed: None,
969 is_active: true,
970 is_archived: false,
971 access_count: 0,
972 }
973 }
974
975 async fn setup_repo() -> (sqlx::SqlitePool, MemoryRepository, i64) {
978 let pool = SqlitePoolOptions::new()
979 .max_connections(1)
980 .connect("sqlite::memory:")
981 .await
982 .unwrap();
983 nexus_storage::migrations::run_migrations(&pool)
984 .await
985 .unwrap();
986 let namespace_repo = NamespaceRepository::new(pool.clone());
987 let namespace = namespace_repo
988 .get_or_create("reflect-test", "reflect-test")
989 .await
990 .unwrap();
991 let repo = MemoryRepository::new(pool.clone());
992 (pool, repo, namespace.id)
993 }
994
995 fn explicit_metadata(observer: &str) -> serde_json::Value {
996 let cognitive = CognitiveMetadata::new(
997 CognitiveLevel::Explicit,
998 observer,
999 observer,
1000 None,
1001 "derive_service",
1002 );
1003 cognitive.merge_into(&serde_json::json!({}))
1004 }
1005
1006 fn derived_metadata(observer: &str) -> serde_json::Value {
1007 let cognitive = CognitiveMetadata::new(
1008 CognitiveLevel::Derived,
1009 observer,
1010 observer,
1011 None,
1012 "derive_service",
1013 );
1014 cognitive.merge_into(&serde_json::json!({}))
1015 }
1016
1017 fn raw_metadata() -> serde_json::Value {
1018 let cognitive = CognitiveMetadata::new(
1019 CognitiveLevel::Raw,
1020 "claude-code",
1021 "claude-code",
1022 None,
1023 "ingest_service",
1024 );
1025 cognitive.merge_into(&serde_json::json!({}))
1026 }
1027
1028 async fn store_memory(
1029 repo: &MemoryRepository,
1030 namespace_id: i64,
1031 content: &str,
1032 metadata: &serde_json::Value,
1033 ) -> Memory {
1034 repo.store(StoreMemoryParams {
1035 namespace_id,
1036 content,
1037 category: &MemoryCategory::Facts,
1038 memory_lane_type: Some(&MemoryLaneType::Priority(MemoryLanePriorityType::Decision)),
1039 labels: &["test".to_string()],
1040 metadata,
1041 embedding: None,
1042 embedding_model: None,
1043 })
1044 .await
1045 .unwrap()
1046 }
1047
1048 #[test]
1051 fn test_word_jaccard_identical() {
1052 assert!((word_jaccard("hello world", "hello world") - 1.0).abs() < f32::EPSILON);
1053 }
1054
1055 #[test]
1056 fn test_word_jaccard_disjoint() {
1057 assert!((word_jaccard("alpha beta", "gamma delta")).abs() < f32::EPSILON);
1058 }
1059
1060 #[test]
1061 fn test_word_jaccard_partial() {
1062 let j = word_jaccard(
1063 "the query service handles search",
1064 "the query service handles pagination",
1065 );
1066 assert!(j > 0.5, "expected partial overlap, got {}", j);
1067 }
1068
1069 #[test]
1070 fn test_word_jaccard_empty_strings() {
1071 assert!((word_jaccard("", "") - 1.0).abs() < f32::EPSILON);
1072 }
1073
1074 #[test]
1075 fn test_word_jaccard_one_empty() {
1076 assert!((word_jaccard("hello", "")).abs() < f32::EPSILON);
1077 }
1078
1079 #[test]
1080 fn test_compare_pair_similar_content_reinforces() {
1081 let left = test_memory(
1082 1,
1083 "The query service handles search requests",
1084 explicit_metadata("claude-code"),
1085 );
1086 let right = test_memory(
1087 2,
1088 "The query service handles search requests efficiently",
1089 explicit_metadata("claude-code"),
1090 );
1091
1092 assert_eq!(
1093 compare_pair(&left, &right),
1094 Some(ReflectionCase::Reinforcement)
1095 );
1096 }
1097
1098 #[test]
1099 fn test_compare_pair_contradiction_pattern() {
1100 let left = test_memory(
1101 1,
1102 "The cache system is enabled and improves performance",
1103 explicit_metadata("claude-code"),
1104 );
1105 let right = test_memory(
1106 2,
1107 "The cache system is not enabled and degrades performance",
1108 explicit_metadata("claude-code"),
1109 );
1110
1111 assert_eq!(
1112 compare_pair(&left, &right),
1113 Some(ReflectionCase::Contradiction)
1114 );
1115 }
1116
1117 #[test]
1118 fn test_compare_pair_unrelated() {
1119 let left = test_memory(
1120 1,
1121 "Fixed pagination bug in search endpoint",
1122 explicit_metadata("claude-code"),
1123 );
1124 let right = test_memory(
1125 2,
1126 "Updated deployment configuration for staging",
1127 explicit_metadata("claude-code"),
1128 );
1129
1130 assert_eq!(compare_pair(&left, &right), None);
1131 }
1132
1133 #[test]
1134 fn test_has_negation_contradiction_detects_negation() {
1135 assert!(has_negation_contradiction(
1136 "the feature is not working correctly",
1137 "the feature is working correctly"
1138 ));
1139 }
1140
1141 #[test]
1142 fn test_has_negation_contradiction_no_negation() {
1143 assert!(!has_negation_contradiction(
1144 "the feature works well",
1145 "the feature is fast"
1146 ));
1147 }
1148
1149 #[tokio::test]
1152 async fn test_reflect_cycle_empty_namespace() {
1153 let (_pool, repo, namespace_id) = setup_repo().await;
1154 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1155
1156 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1157 assert_eq!(result.memories_scanned, 0);
1158 assert_eq!(result.pairs_compared, 0);
1159 assert_eq!(result.reinforcements, 0);
1160 assert_eq!(result.insights_created, 0);
1161 assert_eq!(result.contradictions_created, 0);
1162 }
1163
1164 #[tokio::test]
1165 async fn test_reflect_cycle_skips_raw_memories() {
1166 let (_pool, repo, namespace_id) = setup_repo().await;
1167
1168 store_memory(&repo, namespace_id, "raw noise event", &raw_metadata()).await;
1169
1170 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1171 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1172
1173 assert_eq!(result.memories_scanned, 0);
1174 }
1175
1176 #[tokio::test]
1177 async fn test_reflect_cycle_detects_reinforcement() {
1178 let (_pool, repo, namespace_id) = setup_repo().await;
1179
1180 let left = store_memory(
1181 &repo,
1182 namespace_id,
1183 "The query service handles search requests",
1184 &explicit_metadata("claude-code"),
1185 )
1186 .await;
1187 let right = store_memory(
1188 &repo,
1189 namespace_id,
1190 "The query service handles search requests efficiently",
1191 &explicit_metadata("claude-code"),
1192 )
1193 .await;
1194
1195 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1196 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1197
1198 assert_eq!(result.memories_scanned, 2);
1199 assert!(
1200 result.reinforcements >= 1,
1201 "expected at least 1 reinforcement"
1202 );
1203 assert_eq!(result.contradictions_created, 0);
1204
1205 let left = repo.get_by_id(left.id).await.unwrap().unwrap();
1206 let right = repo.get_by_id(right.id).await.unwrap().unwrap();
1207 let left_cognitive = CognitiveMetadata::from_metadata(&left.metadata).unwrap();
1208 let right_cognitive = CognitiveMetadata::from_metadata(&right.metadata).unwrap();
1209 assert_eq!(left_cognitive.times_reinforced, 1);
1210 assert_eq!(right_cognitive.times_reinforced, 1);
1211 }
1212
1213 #[tokio::test]
1214 async fn test_reflect_cycle_detects_contradiction() {
1215 let (_pool, repo, namespace_id) = setup_repo().await;
1216
1217 let left = store_memory(
1218 &repo,
1219 namespace_id,
1220 "The cache system is enabled and improves performance",
1221 &explicit_metadata("claude-code"),
1222 )
1223 .await;
1224 let right = store_memory(
1225 &repo,
1226 namespace_id,
1227 "The cache system is not enabled and degrades performance",
1228 &explicit_metadata("claude-code"),
1229 )
1230 .await;
1231
1232 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1233 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1234
1235 assert_eq!(result.memories_scanned, 2);
1236 assert_eq!(result.contradictions_created, 1);
1237 assert_eq!(result.contradiction_ids.len(), 1);
1238
1239 let left = repo.get_by_id(left.id).await.unwrap().unwrap();
1240 let right = repo.get_by_id(right.id).await.unwrap().unwrap();
1241 let left_cognitive = CognitiveMetadata::from_metadata(&left.metadata).unwrap();
1242 let right_cognitive = CognitiveMetadata::from_metadata(&right.metadata).unwrap();
1243 assert_eq!(left_cognitive.times_contradicted, 1);
1244 assert_eq!(right_cognitive.times_contradicted, 1);
1245 }
1246
1247 #[tokio::test]
1248 async fn test_reflect_cycle_is_idempotent() {
1249 let (_pool, repo, namespace_id) = setup_repo().await;
1250
1251 store_memory(
1252 &repo,
1253 namespace_id,
1254 "The query service handles search requests",
1255 &explicit_metadata("claude-code"),
1256 )
1257 .await;
1258 store_memory(
1259 &repo,
1260 namespace_id,
1261 "The query service handles search requests efficiently",
1262 &explicit_metadata("claude-code"),
1263 )
1264 .await;
1265
1266 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1267
1268 let result1 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1270 let first_reinforcements = result1.reinforcements;
1271
1272 let result2 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1274 assert_eq!(
1275 result2.reinforcements, 0,
1276 "second pass should not create duplicate reinforcements"
1277 );
1278 assert!(first_reinforcements >= 1);
1280 }
1281
1282 #[tokio::test]
1283 async fn test_reinforcement_creates_evidence_links() {
1284 let (_pool, repo, namespace_id) = setup_repo().await;
1285
1286 let m1 = store_memory(
1287 &repo,
1288 namespace_id,
1289 "The query service handles search requests",
1290 &explicit_metadata("claude-code"),
1291 )
1292 .await;
1293 let m2 = store_memory(
1294 &repo,
1295 namespace_id,
1296 "The query service handles search requests efficiently",
1297 &explicit_metadata("claude-code"),
1298 )
1299 .await;
1300
1301 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1302 service.reflect_cycle(namespace_id, &repo).await.unwrap();
1303
1304 let lineage1 = repo.load_lineage(m1.id).await.unwrap();
1307 let reinforcement_ids: Vec<i64> = lineage1
1308 .iter()
1309 .filter(|e| e.evidence_role == REINFORCE_EVIDENCE_ROLE)
1310 .map(|e| e.derived_memory_id)
1311 .collect();
1312
1313 let lineage2 = repo.load_lineage(m2.id).await.unwrap();
1314 let shared: Vec<i64> = lineage2
1315 .iter()
1316 .filter(|e| {
1317 e.evidence_role == REINFORCE_EVIDENCE_ROLE
1318 && reinforcement_ids.contains(&e.derived_memory_id)
1319 })
1320 .map(|e| e.derived_memory_id)
1321 .collect();
1322
1323 assert!(
1324 !shared.is_empty(),
1325 "expected shared reinforcement memory linking both sources"
1326 );
1327 }
1328
1329 #[tokio::test]
1330 async fn test_contradiction_stores_with_correct_metadata() {
1331 let (_pool, repo, namespace_id) = setup_repo().await;
1332
1333 store_memory(
1334 &repo,
1335 namespace_id,
1336 "The cache system is enabled and improves performance",
1337 &explicit_metadata("claude-code"),
1338 )
1339 .await;
1340 store_memory(
1341 &repo,
1342 namespace_id,
1343 "The cache system is not enabled and degrades performance",
1344 &explicit_metadata("claude-code"),
1345 )
1346 .await;
1347
1348 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1349 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1350 assert_eq!(result.contradiction_ids.len(), 1);
1351
1352 let contradiction_id = result.contradiction_ids[0];
1354 let memories = repo
1355 .list_filtered(
1356 namespace_id,
1357 ListMemoryFilters {
1358 category: None,
1359 since: None,
1360 until: None,
1361 content_like: Some("Contradiction"),
1362 include_raw: false,
1363 limit: 10,
1364 offset: 0,
1365 },
1366 )
1367 .await
1368 .unwrap();
1369
1370 let contradiction = memories
1371 .iter()
1372 .find(|m| m.id == contradiction_id)
1373 .expect("contradiction memory should be retrievable");
1374
1375 let cognitive = CognitiveMetadata::from_metadata(&contradiction.metadata)
1376 .expect("contradiction memory should have cognitive metadata");
1377 assert_eq!(cognitive.level, CognitiveLevel::Contradiction);
1378 assert_eq!(cognitive.generated_by, REFLECT_GENERATED_BY);
1379 assert_eq!(cognitive.source_memory_ids.len(), 2);
1380 assert!(cognitive.confidence.is_some());
1381 assert!(cognitive.confidence.unwrap() > 0.0);
1382
1383 let lineage = repo.load_lineage(contradiction_id).await.unwrap();
1385 assert!(
1386 lineage
1387 .iter()
1388 .any(|e| e.evidence_role == CONTRADICT_EVIDENCE_ROLE),
1389 "contradiction memory should have contradicts evidence"
1390 );
1391 }
1392
1393 #[tokio::test]
1394 async fn test_reflect_cycle_handles_derived_level() {
1395 let (_pool, repo, namespace_id) = setup_repo().await;
1396
1397 store_memory(
1398 &repo,
1399 namespace_id,
1400 "The query service handles search requests",
1401 &derived_metadata("claude-code"),
1402 )
1403 .await;
1404 store_memory(
1405 &repo,
1406 namespace_id,
1407 "The query service handles search requests efficiently",
1408 &derived_metadata("claude-code"),
1409 )
1410 .await;
1411
1412 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1413 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1414
1415 assert_eq!(result.memories_scanned, 2);
1416 assert!(result.reinforcements >= 1);
1417 }
1418
1419 #[tokio::test]
1420 async fn test_reflect_cycle_creates_higher_order_insight() {
1421 let (_pool, repo, namespace_id) = setup_repo().await;
1422
1423 for content in [
1424 "The query service handles search requests",
1425 "The query service handles search requests efficiently",
1426 "The query service handles search requests reliably",
1427 ] {
1428 store_memory(
1429 &repo,
1430 namespace_id,
1431 content,
1432 &explicit_metadata("claude-code"),
1433 )
1434 .await;
1435 }
1436
1437 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1438 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1439
1440 assert_eq!(result.insights_created, 1);
1441 assert_eq!(result.insight_ids.len(), 1);
1442
1443 let insight = repo
1444 .get_by_id(result.insight_ids[0])
1445 .await
1446 .unwrap()
1447 .unwrap();
1448 assert!(insight.content.starts_with("Dream insight:"));
1449 assert!(insight.labels.iter().any(|label| label == "insight"));
1450 let cognitive = CognitiveMetadata::from_metadata(&insight.metadata).unwrap();
1451 assert_eq!(cognitive.level, CognitiveLevel::Derived);
1452 assert_eq!(cognitive.source_memory_ids.len(), 3);
1453 assert_eq!(cognitive.times_reinforced, 3);
1454
1455 let lineage = repo.load_lineage(insight.id).await.unwrap();
1456 let evidence_count = lineage
1457 .iter()
1458 .filter(|entry| entry.evidence_role == INSIGHT_EVIDENCE_ROLE)
1459 .count();
1460 assert_eq!(evidence_count, 3);
1461 }
1462
1463 #[tokio::test]
1464 async fn test_reflect_cycle_contradiction_idempotent() {
1465 let (_pool, repo, namespace_id) = setup_repo().await;
1466
1467 store_memory(
1468 &repo,
1469 namespace_id,
1470 "The cache system is enabled and improves performance",
1471 &explicit_metadata("claude-code"),
1472 )
1473 .await;
1474 store_memory(
1475 &repo,
1476 namespace_id,
1477 "The cache system is not enabled and degrades performance",
1478 &explicit_metadata("claude-code"),
1479 )
1480 .await;
1481
1482 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1483
1484 let result1 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1485 assert_eq!(result1.contradictions_created, 1);
1486
1487 let result2 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1488 assert_eq!(
1489 result2.contradictions_created, 0,
1490 "second pass should not create duplicate contradictions"
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn test_reflect_cycle_insight_idempotent() {
1496 let (_pool, repo, namespace_id) = setup_repo().await;
1497
1498 for content in [
1499 "The query service handles search requests",
1500 "The query service handles search requests efficiently",
1501 "The query service handles search requests reliably",
1502 ] {
1503 store_memory(
1504 &repo,
1505 namespace_id,
1506 content,
1507 &explicit_metadata("claude-code"),
1508 )
1509 .await;
1510 }
1511
1512 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1513 let first = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1514 let second = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1515
1516 assert_eq!(first.insights_created, 1);
1517 assert_eq!(second.insights_created, 0);
1518
1519 let insights = repo
1520 .list_filtered(
1521 namespace_id,
1522 ListMemoryFilters {
1523 category: None,
1524 since: None,
1525 until: None,
1526 content_like: Some("Dream insight:"),
1527 include_raw: false,
1528 limit: 10,
1529 offset: 0,
1530 },
1531 )
1532 .await
1533 .unwrap();
1534 assert_eq!(insights.len(), 1);
1535 }
1536
1537 #[tokio::test]
1538 async fn test_contradiction_gets_embedding_when_service_provided() {
1539 let (_pool, repo, namespace_id) = setup_repo().await;
1540
1541 store_memory(
1542 &repo,
1543 namespace_id,
1544 "The cache system is enabled and improves performance",
1545 &explicit_metadata("claude-code"),
1546 )
1547 .await;
1548 store_memory(
1549 &repo,
1550 namespace_id,
1551 "The cache system is not enabled and degrades performance",
1552 &explicit_metadata("claude-code"),
1553 )
1554 .await;
1555
1556 let mock_embed = nexus_embeddings::MockEmbeddingService::new();
1557 let service = ReflectService::new(
1558 AgentConfig::default(),
1559 CognitionConfig::default(),
1560 Some(Arc::new(mock_embed)),
1561 );
1562 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1563
1564 assert_eq!(result.contradiction_ids.len(), 1);
1565
1566 let contradiction = repo
1567 .get_by_id(result.contradiction_ids[0])
1568 .await
1569 .unwrap()
1570 .unwrap();
1571 assert!(
1572 contradiction.content_embedding.is_some(),
1573 "contradiction memory should have an embedding when service is provided"
1574 );
1575 assert_eq!(
1576 contradiction.content_embedding.as_ref().unwrap().len(),
1577 384,
1578 "embedding dimension should be 384"
1579 );
1580 }
1581
1582 #[tokio::test]
1583 async fn test_insight_gets_embedding_when_service_provided() {
1584 let (_pool, repo, namespace_id) = setup_repo().await;
1585
1586 for content in [
1587 "The query service handles search requests",
1588 "The query service handles search requests efficiently",
1589 "The query service handles search requests reliably",
1590 ] {
1591 store_memory(
1592 &repo,
1593 namespace_id,
1594 content,
1595 &explicit_metadata("claude-code"),
1596 )
1597 .await;
1598 }
1599
1600 let mock_embed = nexus_embeddings::MockEmbeddingService::new();
1601 let service = ReflectService::new(
1602 AgentConfig::default(),
1603 CognitionConfig::default(),
1604 Some(Arc::new(mock_embed)),
1605 );
1606 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1607
1608 assert_eq!(result.insights_created, 1);
1609
1610 let insight = repo
1611 .get_by_id(result.insight_ids[0])
1612 .await
1613 .unwrap()
1614 .unwrap();
1615 assert!(
1616 insight.content_embedding.is_some(),
1617 "insight memory should have an embedding when service is provided"
1618 );
1619 assert_eq!(
1620 insight.content_embedding.as_ref().unwrap().len(),
1621 384,
1622 "embedding dimension should be 384"
1623 );
1624 }
1625
1626 #[tokio::test]
1627 async fn test_reflect_stores_without_embedding_when_service_absent() {
1628 let (_pool, repo, namespace_id) = setup_repo().await;
1629
1630 store_memory(
1631 &repo,
1632 namespace_id,
1633 "The cache system is enabled and improves performance",
1634 &explicit_metadata("claude-code"),
1635 )
1636 .await;
1637 store_memory(
1638 &repo,
1639 namespace_id,
1640 "The cache system is not enabled and degrades performance",
1641 &explicit_metadata("claude-code"),
1642 )
1643 .await;
1644
1645 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1646 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1647 assert_eq!(result.contradiction_ids.len(), 1);
1648
1649 let contradiction = repo
1650 .get_by_id(result.contradiction_ids[0])
1651 .await
1652 .unwrap()
1653 .unwrap();
1654 assert!(
1655 contradiction.content_embedding.is_none(),
1656 "contradiction memory should NOT have embedding when no service provided"
1657 );
1658 }
1659
1660 #[tokio::test]
1661 async fn test_reflect_cycle_does_not_cross_perspectives() {
1662 let (_pool, repo, namespace_id) = setup_repo().await;
1663
1664 store_memory(
1665 &repo,
1666 namespace_id,
1667 "The query service handles search requests",
1668 &explicit_metadata("claude-code"),
1669 )
1670 .await;
1671 store_memory(
1672 &repo,
1673 namespace_id,
1674 "The query service handles search requests efficiently",
1675 &explicit_metadata("codex"),
1676 )
1677 .await;
1678
1679 let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1680 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1681
1682 assert_eq!(result.memories_scanned, 2);
1683 assert_eq!(result.pairs_compared, 0);
1684 assert_eq!(result.reinforcements, 0);
1685 assert_eq!(result.contradictions_created, 0);
1686 }
1687
1688 #[tokio::test]
1691 async fn test_belief_revision_reduces_confidence_on_contradiction() {
1692 let (_pool, repo, namespace_id) = setup_repo().await;
1693
1694 let left = store_memory(
1695 &repo,
1696 namespace_id,
1697 "The cache system is enabled and improves performance",
1698 &explicit_metadata("claude-code"),
1699 )
1700 .await;
1701 let right = store_memory(
1702 &repo,
1703 namespace_id,
1704 "The cache system is not enabled and degrades performance",
1705 &explicit_metadata("claude-code"),
1706 )
1707 .await;
1708
1709 let left_before = repo.get_by_id(left.id).await.unwrap().unwrap();
1711 let right_before = repo.get_by_id(right.id).await.unwrap().unwrap();
1712 let left_conf_before = CognitiveMetadata::from_metadata(&left_before.metadata)
1713 .unwrap()
1714 .confidence;
1715 let right_conf_before = CognitiveMetadata::from_metadata(&right_before.metadata)
1716 .unwrap()
1717 .confidence;
1718
1719 let service = ReflectService::new(
1720 AgentConfig::default(),
1721 CognitionConfig {
1722 contradiction_belief_revision_enabled: true,
1723 contradiction_confidence_penalty: 0.15,
1724 ..CognitionConfig::default()
1725 },
1726 None,
1727 );
1728 let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1729
1730 assert_eq!(result.contradictions_created, 1);
1731
1732 let left_after = repo.get_by_id(left.id).await.unwrap().unwrap();
1733 let right_after = repo.get_by_id(right.id).await.unwrap().unwrap();
1734 let left_cognitive = CognitiveMetadata::from_metadata(&left_after.metadata).unwrap();
1735 let right_cognitive = CognitiveMetadata::from_metadata(&right_after.metadata).unwrap();
1736
1737 assert!(
1739 left_cognitive.confidence.unwrap_or(0.75) < left_conf_before.unwrap_or(0.75),
1740 "left confidence should decrease after belief revision"
1741 );
1742 assert!(
1743 right_cognitive.confidence.unwrap_or(0.75) < right_conf_before.unwrap_or(0.75),
1744 "right confidence should decrease after belief revision"
1745 );
1746 assert!(left_cognitive.last_belief_revision.is_some());
1748 assert!(right_cognitive.last_belief_revision.is_some());
1749 assert_eq!(left_cognitive.resolution_status.as_deref(), Some("revised"));
1751 assert_eq!(
1752 right_cognitive.resolution_status.as_deref(),
1753 Some("revised")
1754 );
1755 }
1756
1757 #[tokio::test]
1758 async fn test_belief_revision_disabled_does_not_modify_confidence() {
1759 let (_pool, repo, namespace_id) = setup_repo().await;
1760
1761 store_memory(
1762 &repo,
1763 namespace_id,
1764 "The cache system is enabled",
1765 &explicit_metadata("claude-code"),
1766 )
1767 .await;
1768 store_memory(
1769 &repo,
1770 namespace_id,
1771 "The cache system is not enabled",
1772 &explicit_metadata("claude-code"),
1773 )
1774 .await;
1775
1776 let service = ReflectService::new(
1777 AgentConfig::default(),
1778 CognitionConfig {
1779 contradiction_belief_revision_enabled: false,
1780 ..CognitionConfig::default()
1781 },
1782 None,
1783 );
1784 let _result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1785
1786 let memories = repo
1788 .list_filtered(
1789 namespace_id,
1790 ListMemoryFilters {
1791 category: None,
1792 since: None,
1793 until: None,
1794 content_like: None,
1795 include_raw: false,
1796 limit: 10,
1797 offset: 0,
1798 },
1799 )
1800 .await
1801 .unwrap();
1802
1803 for memory in memories {
1804 if let Some(cognitive) = CognitiveMetadata::from_metadata(&memory.metadata) {
1805 assert!(
1806 cognitive.last_belief_revision.is_none(),
1807 "no belief revision should occur when disabled"
1808 );
1809 assert!(
1810 cognitive.resolution_status.is_none(),
1811 "no resolution status when belief revision disabled"
1812 );
1813 }
1814 }
1815 }
1816}