Skip to main content

nexus_memory_agent/
reflect.rs

1//! Reflection service - deterministic reinforcement and contradiction detection.
2//!
3//! Implements a typed, deterministic reflection pipeline that scans perspective-aligned
4//! memories for reinforcement patterns and simple contradiction cases. Outputs are
5//! persisted as derived/contradiction memories with evidence lineage.
6//!
7//! Explicit seam: the `ReflectService` struct is designed to accept an optional LLM
8//! client in future phases for deeper semantic reflection, but the current slice
9//! operates entirely through deterministic content analysis.
10
11use 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
30// ---------------------------------------------------------------------------
31// Constants
32// ---------------------------------------------------------------------------
33
34const 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
42/// Word-level Jaccard similarity threshold for reinforcement detection.
43const REINFORCE_SIMILARITY_THRESHOLD: f32 = 0.80;
44const INSIGHT_SIMILARITY_THRESHOLD: f32 = 0.55;
45
46/// Minimum topic overlap for contradiction candidate consideration.
47const CONTRADICTION_MIN_TOPIC_OVERLAP: f32 = 0.30;
48
49/// Negation words that signal contradiction when paired with an affirmative claim.
50const 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
68/// Stop words excluded from topic comparison.
69const 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// ---------------------------------------------------------------------------
79// Public types
80// ---------------------------------------------------------------------------
81
82/// What kind of reflection was detected between memories.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ReflectionCase {
85    /// Memories reinforce the same observation (high content similarity, same level).
86    Reinforcement,
87    /// Memories assert contradictory claims about the same topic.
88    Contradiction,
89}
90
91/// Output of a single reflection comparison between two memories.
92#[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/// Summary of a full reflection cycle.
101#[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
112// ---------------------------------------------------------------------------
113// Service
114// ---------------------------------------------------------------------------
115
116/// Deterministic reflection engine for reinforcement and contradiction detection.
117///
118/// Operates over perspective-aligned Explicit and Derived memories within a single
119/// namespace. Each cycle is bounded by `MAX_CANDIDATES` and idempotent — pairs that
120/// already have evidence links are skipped.
121pub 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    /// Run a single bounded reflection cycle over the namespace's memories.
141    ///
142    /// Scans up to `MAX_CANDIDATES` non-raw memories, compares all eligible pairs,
143    /// and persists reinforcement or contradiction outputs. Returns a summary of
144    /// what was found and created.
145    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    /// Compare two memories and return the reflection case, if any.
223    ///
224    /// This is the pure deterministic comparison logic, exposed for testing and
225    /// future LLM augmentation hooks.
226    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
296// ---------------------------------------------------------------------------
297// Candidate gathering
298// ---------------------------------------------------------------------------
299
300async 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    // Only consider Explicit and Derived level memories for reflection.
321    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
347// ---------------------------------------------------------------------------
348// Idempotency: evidence-based dedup
349// ---------------------------------------------------------------------------
350
351type 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
361/// Load evidence-based pair tracking for idempotency.
362///
363/// Evidence rows created by `store_with_lineage` link a reflection memory (derived)
364/// to each source individually. To detect that a pair of sources already shares a
365/// reflection, we collect all reflection-memory → source links and group sources by
366/// their shared reflection memory. Any two sources linked to the same reflection
367/// memory are considered an already-processed pair.
368async 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    // Map: reflection_memory_id → Vec<source_memory_id>
379    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                    // The reflection memory is derived_memory_id; the source is source_memory_id.
389                    // But load_lineage returns rows where either column matches mem.id,
390                    // so we need to figure out which is the reflection and which is the source.
391                    // Convention: store_with_lineage puts the NEW memory as derived_memory_id
392                    // and the ORIGINAL as source_memory_id.
393                    // Since we're loading lineage for a candidate (original), the candidate
394                    // will appear as source_memory_id in the evidence row.
395                    reflection_to_sources
396                        .entry(reflection_id)
397                        .or_default()
398                        .push(source_id);
399                }
400            }
401        }
402    }
403
404    // Any two sources sharing the same reflection memory form a processed pair.
405    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
416// ---------------------------------------------------------------------------
417// Deterministic comparison
418// ---------------------------------------------------------------------------
419
420fn compare_pair(left: &Memory, right: &Memory) -> Option<ReflectionCase> {
421    let similarity = word_jaccard(&left.content, &right.content);
422
423    // High similarity → reinforcement.
424    if similarity >= REINFORCE_SIMILARITY_THRESHOLD {
425        return Some(ReflectionCase::Reinforcement);
426    }
427
428    // Moderate topic overlap with negation pattern → contradiction.
429    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
462/// Check if `base_words` contains a negated version of a claim present in `other_words`.
463///
464/// Looks for patterns where `other` has a word/token and `base` has that same
465/// word preceded by a negation word within a 2-word window.
466fn 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            // Check the next 1-2 words for a content word also present in the other set.
473            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
490// ---------------------------------------------------------------------------
491// Reinforcement handling
492// ---------------------------------------------------------------------------
493
494async 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
551// ---------------------------------------------------------------------------
552// Contradiction handling
553// ---------------------------------------------------------------------------
554
555async 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    // Combined increment + belief revision: one load, one parse, one write per memory.
609    // Previous code called increment_cognitive_counter and apply_belief_revision
610    // separately, causing 4 DB loads/writes for a single contradiction pair.
611    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    // Parse cognitive metadata exactly once.  The snapshot call was redundant
653    // here because we need the full CognitiveMetadata struct to merge_into
654    // anyway; the snapshot only stores a subset of its fields.
655    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
688/// Combined increment + belief revision for contradiction handling.
689///
690/// Performs both `increment_cognitive_counter("times_contradicted")` and
691/// `apply_belief_revision` in a single DB load/parse/write cycle, halving the
692/// round-trips compared to calling them separately.
693async 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(&current) {
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    // Find a safe char boundary.
929    let mut end = max_chars;
930    while end > 0 && !s.is_char_boundary(end) {
931        end -= 1;
932    }
933    // Try to break at a word boundary.
934    if let Some(space_pos) = s[..end].rfind(' ') {
935        return &s[..space_pos];
936    }
937    &s[..end]
938}
939
940// ---------------------------------------------------------------------------
941// Tests
942// ---------------------------------------------------------------------------
943
944#[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    // ---- Helpers ----
976
977    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    // ---- Unit tests: comparison logic ----
1049
1050    #[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    // ---- Integration tests: full cycle ----
1150
1151    #[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        // First pass.
1269        let result1 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1270        let first_reinforcements = result1.reinforcements;
1271
1272        // Second pass — should be idempotent.
1273        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        // First pass result is still valid.
1279        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        // Verify evidence links exist: both m1 and m2 should share a reinforcement
1305        // link to the same reflection memory.
1306        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        // Fetch the contradiction memory and verify its cognitive metadata.
1353        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        // Verify evidence links.
1384        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    // ---- Belief revision tests (Phase 16) ----
1689
1690    #[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        // Both start with default confidence (None → 0.75).
1710        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        // Confidence should have decreased (use unwrap_or for Option comparison).
1738        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        // Belief revision timestamp should be set.
1747        assert!(left_cognitive.last_belief_revision.is_some());
1748        assert!(right_cognitive.last_belief_revision.is_some());
1749        // Resolution status should be "revised".
1750        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        // Check that no memories got belief revision stamps.
1787        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}