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}