Skip to main content

zeph_memory/graph/
belief.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pre-commitment probabilistic edge layer for the APEX-MEM knowledge graph.
5//!
6//! [`BeliefStore`] implements a staging area for candidate facts that lack sufficient
7//! confidence for immediate commitment to the committed `graph_edges` store.
8//! Evidence events for the same `(source, canonical_relation, target, edge_type)` tuple
9//! are accumulated via the Noisy-OR rule. When the cumulative probability crosses
10//! [`BeliefMemConfig::promote_threshold`], the caller should promote the belief to a
11//! committed edge via `GraphStore::insert_or_supersede`.
12//!
13//! # Relationship to APEX-MEM
14//!
15//! - APEX-MEM conflict resolution operates **post-commitment** (multiple committed heads).
16//! - `BeliefStore` operates **pre-commitment** (accumulates evidence before the first commit).
17//! - Promotion from `BeliefStore` → APEX-MEM uses the standard `insert_or_supersede` path.
18//!
19//! # Key invariants
20//!
21//! - `prob` is monotonically non-decreasing for an active (non-promoted) belief.
22//! - Promotion is one-way: once `promoted_at` is set, the belief never re-enters pending.
23//! - Retrieval from `pending_beliefs` is a fallback: only used when no committed edge exists.
24//! - Noisy-OR guarantees `prob ∈ (0, 1)` given inputs in `(0, 1)`.
25
26use tracing::instrument;
27use zeph_db::{DbPool, sql};
28
29use crate::error::MemoryError;
30use crate::graph::types::EdgeType;
31
32// ── Pure functions ────────────────────────────────────────────────────────────
33
34/// Combine two independent evidence probabilities via the Noisy-OR rule.
35///
36/// Noisy-OR models independent failure modes: `P(A ∨ B) = 1 − (1 − p_a)(1 − p_b)`.
37/// The result is always strictly greater than either input and strictly less than 1.
38///
39/// Both arguments must be in the open interval `(0.0, 1.0)`.
40///
41/// # Examples
42///
43/// ```
44/// use zeph_memory::graph::belief::noisy_or;
45///
46/// let combined = noisy_or(0.4, 0.5);
47/// assert!((combined - 0.7).abs() < 1e-6);
48/// ```
49#[inline]
50#[must_use]
51pub fn noisy_or(p_existing: f32, p_new: f32) -> f32 {
52    debug_assert!(
53        p_existing > 0.0 && p_existing < 1.0,
54        "p_existing out of range: {p_existing}"
55    );
56    debug_assert!(p_new > 0.0 && p_new < 1.0, "p_new out of range: {p_new}");
57    1.0 - (1.0 - p_existing) * (1.0 - p_new)
58}
59
60/// Apply exponential temporal decay to a probability.
61///
62/// Used before applying a new Noisy-OR update to discount stale evidence:
63/// `p_decayed = p * exp(-λ * days)`.
64///
65/// - `prob`: current probability in `(0, 1)`.
66/// - `days_since_update`: elapsed time in fractional days (may be 0.0).
67/// - `decay_rate`: λ (0.01 by default in [`BeliefMemConfig`]).
68///
69/// Returns a value clamped to `(0.0, 1.0)`.
70///
71/// # Examples
72///
73/// ```
74/// use zeph_memory::graph::belief::time_decayed_prob;
75///
76/// // 30 days at λ=0.01 → multiplier ≈ 0.74
77/// let decayed = time_decayed_prob(0.8, 30.0, 0.01);
78/// assert!(decayed < 0.8);
79/// assert!(decayed > 0.0);
80/// ```
81#[inline]
82#[must_use]
83pub fn time_decayed_prob(prob: f32, days_since_update: f64, decay_rate: f32) -> f32 {
84    #[allow(clippy::cast_possible_truncation)]
85    let multiplier = (-f64::from(decay_rate) * days_since_update).exp() as f32;
86    (prob * multiplier).clamp(f32::MIN_POSITIVE, 1.0 - f32::EPSILON)
87}
88
89// ── Types ─────────────────────────────────────────────────────────────────────
90
91/// A candidate edge that has not yet crossed the promotion threshold.
92///
93/// Stored in `pending_beliefs`. Evidence events accumulate via Noisy-OR until
94/// `prob >= BeliefMemConfig::promote_threshold`, at which point the caller promotes
95/// the belief to a committed `graph_edges` row.
96#[derive(Debug, Clone, PartialEq)]
97pub struct PendingBelief {
98    /// Unique row identifier.
99    pub id: i64,
100    /// Source entity (`graph_entities.id`).
101    pub source_entity_id: i64,
102    /// Target entity (`graph_entities.id`).
103    pub target_entity_id: i64,
104    /// Original relation verb as extracted from the message.
105    pub relation: String,
106    /// Normalised relation used for deduplication and indexing.
107    pub canonical_relation: String,
108    /// Human-readable sentence summarising the relationship.
109    pub fact: String,
110    /// MAGMA edge type.
111    pub edge_type: EdgeType,
112    /// Current cumulative probability in `(0.0, 1.0)`.
113    pub prob: f32,
114    /// Episode the most recent evidence came from.
115    pub episode_id: Option<String>,
116    /// Unix timestamp (seconds) of the first evidence event.
117    pub created_at: i64,
118    /// Unix timestamp (seconds) of the most recent Noisy-OR update.
119    pub updated_at: i64,
120}
121
122/// A single Noisy-OR update event recorded in `belief_evidence`.
123///
124/// Provides a complete audit trail of how each belief's probability evolved.
125#[derive(Debug, Clone)]
126pub struct BeliefEvidence {
127    /// Unique row identifier.
128    pub id: i64,
129    /// The belief this event belongs to.
130    pub belief_id: i64,
131    /// Probability before this update (after temporal decay if configured).
132    pub prior_prob: f32,
133    /// Probability of the new evidence signal (from the extractor's `confidence` field).
134    pub evidence_prob: f32,
135    /// Probability after applying Noisy-OR: `1 - (1 - prior)(1 - evidence)`.
136    pub posterior_prob: f32,
137    /// Episode the evidence came from.
138    pub episode_id: Option<String>,
139    /// Unix timestamp (seconds) when this evidence was recorded.
140    pub created_at: i64,
141}
142
143/// Configuration for the probabilistic belief layer.
144///
145/// Embed in `[memory.graph.belief_mem]` in `config.toml`. All thresholds are
146/// dimensionless probabilities in `[0.0, 1.0]`.
147#[derive(Debug, Clone)]
148pub struct BeliefMemConfig {
149    /// Whether the feature is enabled. Default: `false`.
150    pub enabled: bool,
151    /// Minimum probability for a new fact to enter `pending_beliefs`.
152    /// Evidence below this is discarded. Default: `0.3`.
153    pub min_entry_prob: f32,
154    /// Promotion threshold: when `prob >= promote_threshold`, the belief is
155    /// returned from [`BeliefStore::record_evidence`] for the caller to commit.
156    /// Default: `0.85`.
157    pub promote_threshold: f32,
158    /// Eviction cap: maximum `pending_beliefs` rows per `(source, canonical_relation)`
159    /// group. Oldest low-probability beliefs are evicted when exceeded. Default: `10`.
160    pub max_candidates_per_group: usize,
161    /// Number of candidates returned by [`BeliefStore::retrieve_candidates`].
162    /// Default: `3`.
163    pub retrieval_top_k: usize,
164    /// Exponential decay rate λ applied to existing probability before each Noisy-OR
165    /// update. Set to `0.0` to disable temporal decay. Default: `0.01`.
166    pub belief_decay_rate: f32,
167}
168
169impl Default for BeliefMemConfig {
170    fn default() -> Self {
171        Self {
172            enabled: false,
173            min_entry_prob: 0.3,
174            promote_threshold: 0.85,
175            max_candidates_per_group: 10,
176            retrieval_top_k: 3,
177            belief_decay_rate: 0.01,
178        }
179    }
180}
181
182// ── BeliefStore ───────────────────────────────────────────────────────────────
183
184/// Persistence layer for the pre-commitment probabilistic edge layer.
185///
186/// All mutations go through this type: creating new beliefs, applying Noisy-OR
187/// evidence updates, marking beliefs as promoted, and evicting stale candidates.
188///
189/// Obtain an instance via [`BeliefStore::new`] after running the `zeph-db` migrations.
190pub struct BeliefStore {
191    pool: DbPool,
192    config: BeliefMemConfig,
193}
194
195impl BeliefStore {
196    /// Create a new `BeliefStore` wrapping `pool` with the given configuration.
197    ///
198    /// # Examples
199    ///
200    /// ```no_run
201    /// use zeph_memory::graph::belief::{BeliefStore, BeliefMemConfig};
202    /// use zeph_db::DbPool;
203    ///
204    /// async fn example(pool: DbPool) {
205    ///     let store = BeliefStore::new(pool, BeliefMemConfig::default());
206    /// }
207    /// ```
208    #[must_use]
209    pub fn new(pool: DbPool, config: BeliefMemConfig) -> Self {
210        Self { pool, config }
211    }
212
213    /// Record new evidence for a candidate edge and apply Noisy-OR accumulation.
214    ///
215    /// If a matching `pending_belief` exists for the same `(source_entity_id,
216    /// target_entity_id, canonical_relation, edge_type)` tuple, this method:
217    /// 1. Applies optional temporal decay to the existing probability.
218    /// 2. Combines the decayed probability with `evidence_prob` via Noisy-OR.
219    /// 3. Persists the update and appends a row to `belief_evidence`.
220    ///
221    /// If no matching belief exists and `evidence_prob >= min_entry_prob`, a new
222    /// belief row is created.
223    ///
224    /// Returns `Some(PendingBelief)` when the updated probability crosses
225    /// `promote_threshold`. The **caller** is responsible for calling
226    /// `GraphStore::insert_or_supersede` to commit the promoted belief, then
227    /// calling [`BeliefStore::mark_promoted`] to record the committed edge ID.
228    ///
229    /// Returns `None` when the belief exists but has not yet crossed the threshold,
230    /// or when `evidence_prob < min_entry_prob` and no prior belief existed.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`MemoryError`] for database failures.
235    #[allow(clippy::too_many_arguments)]
236    #[instrument(
237        name = "memory.graph.belief.record_evidence",
238        skip(self, fact, episode_id),
239        fields(source_entity_id, target_entity_id, canonical_relation, evidence_prob)
240    )]
241    pub async fn record_evidence(
242        &self,
243        source_entity_id: i64,
244        target_entity_id: i64,
245        relation: &str,
246        canonical_relation: &str,
247        fact: &str,
248        edge_type: EdgeType,
249        evidence_prob: f32,
250        episode_id: Option<&str>,
251    ) -> Result<Option<PendingBelief>, MemoryError> {
252        if !self.config.enabled {
253            return Ok(None);
254        }
255        if evidence_prob < self.config.min_entry_prob
256            || evidence_prob <= 0.0
257            || evidence_prob >= 1.0
258        {
259            return Ok(None);
260        }
261
262        let edge_type_str = edge_type.as_str();
263
264        // Check for an existing belief row.
265        let existing = self
266            .find_existing(
267                source_entity_id,
268                target_entity_id,
269                canonical_relation,
270                edge_type_str,
271            )
272            .await?;
273
274        let belief = match existing {
275            Some(row) => {
276                self.apply_evidence_update(row, evidence_prob, episode_id)
277                    .await?
278            }
279            None => {
280                self.insert_new_belief(
281                    source_entity_id,
282                    target_entity_id,
283                    relation,
284                    canonical_relation,
285                    fact,
286                    edge_type_str,
287                    evidence_prob,
288                    episode_id,
289                )
290                .await?
291            }
292        };
293
294        // Evict stale candidates to stay within the per-group cap.
295        self.evict_stale(source_entity_id, canonical_relation)
296            .await?;
297
298        if belief.prob >= self.config.promote_threshold {
299            Ok(Some(belief))
300        } else {
301            Ok(None)
302        }
303    }
304
305    /// Retrieve the top-K unpromoted beliefs for a `(source, canonical_relation)` pair,
306    /// ordered by probability descending.
307    ///
308    /// This is a fallback for graph recall: called only when no committed edge exists.
309    /// Results are annotated by the caller as uncertain (`is_uncertain: true`).
310    ///
311    /// # Errors
312    ///
313    /// Returns [`MemoryError`] for database failures.
314    #[instrument(
315        name = "memory.graph.belief.retrieve_candidates",
316        skip(self),
317        fields(source_entity_id, canonical_relation)
318    )]
319    pub async fn retrieve_candidates(
320        &self,
321        source_entity_id: i64,
322        canonical_relation: &str,
323        top_k: Option<usize>,
324    ) -> Result<Vec<PendingBelief>, MemoryError> {
325        #[allow(clippy::cast_possible_wrap)]
326        let limit = top_k.unwrap_or(self.config.retrieval_top_k) as i64;
327
328        let rows: Vec<BeliefRow> = zeph_db::query_as(sql!(
329            "SELECT id, source_entity_id, target_entity_id, relation, canonical_relation,
330                    fact, edge_type, prob, episode_id, created_at, updated_at
331             FROM pending_beliefs
332             WHERE source_entity_id = ?
333               AND canonical_relation = ?
334               AND promoted_at IS NULL
335             ORDER BY prob DESC
336             LIMIT ?"
337        ))
338        .bind(source_entity_id)
339        .bind(canonical_relation)
340        .bind(limit)
341        .fetch_all(&self.pool)
342        .await?;
343
344        rows.into_iter().map(belief_from_row).collect()
345    }
346
347    /// Mark a belief as promoted and record the committed edge ID.
348    ///
349    /// Sets `promoted_at` to the current Unix timestamp and stores `committed_edge_id`
350    /// so the belief audit trail links to the committed graph edge.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`MemoryError`] for database failures.
355    #[instrument(
356        name = "memory.graph.belief.mark_promoted",
357        skip(self),
358        fields(belief_id, committed_edge_id)
359    )]
360    pub async fn mark_promoted(
361        &self,
362        belief_id: i64,
363        committed_edge_id: i64,
364    ) -> Result<(), MemoryError> {
365        zeph_db::query(sql!(
366            "UPDATE pending_beliefs
367             SET promoted_at = unixepoch(), promoted_edge_id = ?
368             WHERE id = ?"
369        ))
370        .bind(committed_edge_id)
371        .bind(belief_id)
372        .execute(&self.pool)
373        .await?;
374        Ok(())
375    }
376
377    /// Evict old low-probability beliefs for a `(source, canonical_relation)` group
378    /// that exceed [`BeliefMemConfig::max_candidates_per_group`].
379    ///
380    /// The `max_candidates_per_group` highest-probability beliefs are retained;
381    /// the rest are deleted. Returns the number of rows deleted.
382    ///
383    /// # Errors
384    ///
385    /// Returns [`MemoryError`] for database failures.
386    pub async fn evict_stale(
387        &self,
388        source_entity_id: i64,
389        canonical_relation: &str,
390    ) -> Result<usize, MemoryError> {
391        #[allow(clippy::cast_possible_wrap)]
392        let cap = self.config.max_candidates_per_group as i64;
393
394        // NOT IN (subquery) is safe here because `cap` is bounded by
395        // `max_candidates_per_group` (default 10), so the subquery result set is small.
396        // SQLite's query planner uses the covering index idx_pending_beliefs_retrieval for
397        // the inner SELECT, making this O(cap) rather than a full-table scan.
398        let deleted = zeph_db::query(sql!(
399            "DELETE FROM pending_beliefs
400             WHERE source_entity_id = ?
401               AND canonical_relation = ?
402               AND promoted_at IS NULL
403               AND id NOT IN (
404                   SELECT id FROM pending_beliefs
405                   WHERE source_entity_id = ?
406                     AND canonical_relation = ?
407                     AND promoted_at IS NULL
408                   ORDER BY prob DESC
409                   LIMIT ?
410               )"
411        ))
412        .bind(source_entity_id)
413        .bind(canonical_relation)
414        .bind(source_entity_id)
415        .bind(canonical_relation)
416        .bind(cap)
417        .execute(&self.pool)
418        .await?
419        .rows_affected();
420
421        #[allow(clippy::cast_possible_truncation)]
422        Ok(deleted as usize)
423    }
424
425    // ── Private helpers ───────────────────────────────────────────────────────
426
427    async fn find_existing(
428        &self,
429        source_entity_id: i64,
430        target_entity_id: i64,
431        canonical_relation: &str,
432        edge_type_str: &str,
433    ) -> Result<Option<BeliefRow>, MemoryError> {
434        let row: Option<BeliefRow> = zeph_db::query_as(sql!(
435            "SELECT id, source_entity_id, target_entity_id, relation, canonical_relation,
436                    fact, edge_type, prob, episode_id, created_at, updated_at
437             FROM pending_beliefs
438             WHERE source_entity_id = ?
439               AND target_entity_id = ?
440               AND canonical_relation = ?
441               AND edge_type = ?
442               AND promoted_at IS NULL
443             LIMIT 1"
444        ))
445        .bind(source_entity_id)
446        .bind(target_entity_id)
447        .bind(canonical_relation)
448        .bind(edge_type_str)
449        .fetch_optional(&self.pool)
450        .await?;
451        Ok(row)
452    }
453
454    async fn apply_evidence_update(
455        &self,
456        row: BeliefRow,
457        evidence_prob: f32,
458        episode_id: Option<&str>,
459    ) -> Result<PendingBelief, MemoryError> {
460        let prior_prob = if self.config.belief_decay_rate > 0.0 {
461            let now_secs = now_unix();
462            #[allow(clippy::cast_precision_loss)]
463            let days_elapsed = (now_secs - row.updated_at) as f64 / 86_400.0;
464            time_decayed_prob(
465                row.prob,
466                days_elapsed.max(0.0),
467                self.config.belief_decay_rate,
468            )
469        } else {
470            row.prob
471        };
472
473        let posterior = noisy_or(prior_prob, evidence_prob);
474
475        zeph_db::query(sql!(
476            "UPDATE pending_beliefs
477             SET prob = ?, updated_at = unixepoch(), episode_id = ?
478             WHERE id = ?"
479        ))
480        .bind(posterior)
481        .bind(episode_id)
482        .bind(row.id)
483        .execute(&self.pool)
484        .await?;
485
486        zeph_db::query(sql!(
487            "INSERT INTO belief_evidence
488                (belief_id, prior_prob, evidence_prob, posterior_prob, episode_id)
489             VALUES (?, ?, ?, ?, ?)"
490        ))
491        .bind(row.id)
492        .bind(prior_prob)
493        .bind(evidence_prob)
494        .bind(posterior)
495        .bind(episode_id)
496        .execute(&self.pool)
497        .await?;
498
499        belief_from_row(BeliefRow {
500            prob: posterior,
501            updated_at: now_unix(),
502            episode_id: episode_id.map(ToOwned::to_owned),
503            ..row
504        })
505    }
506
507    #[allow(clippy::too_many_arguments)]
508    async fn insert_new_belief(
509        &self,
510        source_entity_id: i64,
511        target_entity_id: i64,
512        relation: &str,
513        canonical_relation: &str,
514        fact: &str,
515        edge_type_str: &str,
516        evidence_prob: f32,
517        episode_id: Option<&str>,
518    ) -> Result<PendingBelief, MemoryError> {
519        let id: i64 = zeph_db::query_scalar(sql!(
520            "INSERT INTO pending_beliefs
521                (source_entity_id, target_entity_id, relation, canonical_relation,
522                 fact, edge_type, prob, episode_id)
523             VALUES (?, ?, ?, ?, ?, ?, ?, ?)
524             RETURNING id"
525        ))
526        .bind(source_entity_id)
527        .bind(target_entity_id)
528        .bind(relation)
529        .bind(canonical_relation)
530        .bind(fact)
531        .bind(edge_type_str)
532        .bind(evidence_prob)
533        .bind(episode_id)
534        .fetch_one(&self.pool)
535        .await?;
536
537        let now = now_unix();
538        zeph_db::query(sql!(
539            "INSERT INTO belief_evidence
540                (belief_id, prior_prob, evidence_prob, posterior_prob, episode_id)
541             VALUES (?, ?, ?, ?, ?)"
542        ))
543        .bind(id)
544        .bind(0.0_f32)
545        .bind(evidence_prob)
546        .bind(evidence_prob)
547        .bind(episode_id)
548        .execute(&self.pool)
549        .await?;
550
551        Ok(PendingBelief {
552            id,
553            source_entity_id,
554            target_entity_id,
555            relation: relation.to_owned(),
556            canonical_relation: canonical_relation.to_owned(),
557            fact: fact.to_owned(),
558            edge_type: edge_type_str.parse::<EdgeType>().unwrap_or_default(),
559            prob: evidence_prob,
560            episode_id: episode_id.map(ToOwned::to_owned),
561            created_at: now,
562            updated_at: now,
563        })
564    }
565}
566
567// ── Database row mapping ──────────────────────────────────────────────────────
568
569#[derive(sqlx::FromRow)]
570struct BeliefRow {
571    id: i64,
572    source_entity_id: i64,
573    target_entity_id: i64,
574    relation: String,
575    canonical_relation: String,
576    fact: String,
577    edge_type: String,
578    prob: f32,
579    episode_id: Option<String>,
580    created_at: i64,
581    updated_at: i64,
582}
583
584fn belief_from_row(row: BeliefRow) -> Result<PendingBelief, MemoryError> {
585    let edge_type = row.edge_type.parse::<EdgeType>().map_err(|e| {
586        MemoryError::GraphStore(format!("invalid edge_type '{}': {e}", row.edge_type))
587    })?;
588    Ok(PendingBelief {
589        id: row.id,
590        source_entity_id: row.source_entity_id,
591        target_entity_id: row.target_entity_id,
592        relation: row.relation,
593        canonical_relation: row.canonical_relation,
594        fact: row.fact,
595        edge_type,
596        prob: row.prob,
597        episode_id: row.episode_id,
598        created_at: row.created_at,
599        updated_at: row.updated_at,
600    })
601}
602
603fn now_unix() -> i64 {
604    use std::time::{SystemTime, UNIX_EPOCH};
605    #[allow(clippy::cast_possible_wrap)]
606    SystemTime::now()
607        .duration_since(UNIX_EPOCH)
608        .map_or(0, |d| d.as_secs() as i64)
609}
610
611// ── Tests ─────────────────────────────────────────────────────────────────────
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn noisy_or_combines_correctly() {
619        // 1 - (1 - 0.4)(1 - 0.5) = 1 - 0.6 * 0.5 = 0.7
620        let result = noisy_or(0.4, 0.5);
621        assert!((result - 0.7).abs() < 1e-6, "got {result}");
622    }
623
624    #[test]
625    fn noisy_or_is_bounded() {
626        let result = noisy_or(0.9, 0.9);
627        assert!(result < 1.0);
628        assert!(result > 0.9);
629    }
630
631    #[test]
632    fn noisy_or_accumulates_above_threshold() {
633        // Six evidence events at 0.3 each should exceed 0.85 (from critic M4 scenario)
634        let mut p = 0.3_f32;
635        for _ in 1..6 {
636            p = noisy_or(p, 0.3);
637        }
638        assert!(p >= 0.85, "accumulated prob {p} did not reach 0.85");
639    }
640
641    #[test]
642    fn time_decayed_prob_reduces_value() {
643        let original = 0.8_f32;
644        let decayed = time_decayed_prob(original, 30.0, 0.01);
645        assert!(decayed < original);
646        assert!(decayed > 0.0);
647    }
648
649    #[test]
650    fn time_decayed_prob_zero_days_unchanged() {
651        let original = 0.7_f32;
652        let decayed = time_decayed_prob(original, 0.0, 0.01);
653        assert!((decayed - original).abs() < 1e-5);
654    }
655
656    #[test]
657    fn time_decayed_prob_zero_rate_unchanged() {
658        let original = 0.6_f32;
659        let decayed = time_decayed_prob(original, 100.0, 0.0);
660        assert!((decayed - original).abs() < 1e-5);
661    }
662
663    #[test]
664    fn time_decayed_prob_stays_in_bounds() {
665        let decayed = time_decayed_prob(0.99, 10_000.0, 1.0);
666        assert!(decayed > 0.0);
667        assert!(decayed < 1.0);
668    }
669
670    #[test]
671    fn belief_mem_config_defaults() {
672        let cfg = BeliefMemConfig::default();
673        assert!(!cfg.enabled);
674        assert!((cfg.min_entry_prob - 0.3).abs() < 1e-6);
675        assert!((cfg.promote_threshold - 0.85).abs() < 1e-6);
676        assert_eq!(cfg.max_candidates_per_group, 10);
677        assert_eq!(cfg.retrieval_top_k, 3);
678        assert!((cfg.belief_decay_rate - 0.01).abs() < 1e-6);
679    }
680}