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