1use tracing::instrument;
27use zeph_db::{ActiveDialect, DbPool, sql};
28
29use crate::error::MemoryError;
30use crate::graph::types::EdgeType;
31
32#[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#[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#[derive(Debug, Clone, PartialEq)]
97pub struct PendingBelief {
98 pub id: i64,
100 pub source_entity_id: i64,
102 pub target_entity_id: i64,
104 pub relation: String,
106 pub canonical_relation: String,
108 pub fact: String,
110 pub edge_type: EdgeType,
112 pub prob: f32,
114 pub episode_id: Option<String>,
116 pub created_at: i64,
118 pub updated_at: i64,
120}
121
122#[derive(Debug, Clone)]
126pub struct BeliefEvidence {
127 pub id: i64,
129 pub belief_id: i64,
131 pub prior_prob: f32,
133 pub evidence_prob: f32,
135 pub posterior_prob: f32,
137 pub episode_id: Option<String>,
139 pub created_at: i64,
141}
142
143#[derive(Debug, Clone)]
148pub struct BeliefMemConfig {
149 pub enabled: bool,
151 pub min_entry_prob: f32,
154 pub promote_threshold: f32,
158 pub max_candidates_per_group: usize,
161 pub retrieval_top_k: usize,
164 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
182pub struct BeliefStore {
191 pool: DbPool,
192 config: BeliefMemConfig,
193}
194
195impl BeliefStore {
196 #[must_use]
209 pub fn new(pool: DbPool, config: BeliefMemConfig) -> Self {
210 Self { pool, config }
211 }
212
213 #[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 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 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 #[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 #[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 let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
366 let raw = format!(
367 "UPDATE pending_beliefs
368 SET promoted_at = {epoch_now}, promoted_edge_id = ?
369 WHERE id = ?"
370 );
371 let sql = zeph_db::rewrite_placeholders(&raw);
372 zeph_db::query(sqlx::AssertSqlSafe(sql))
373 .bind(committed_edge_id)
374 .bind(belief_id)
375 .execute(&self.pool)
376 .await?;
377 Ok(())
378 }
379
380 pub async fn evict_stale(
390 &self,
391 source_entity_id: i64,
392 canonical_relation: &str,
393 ) -> Result<usize, MemoryError> {
394 #[allow(clippy::cast_possible_wrap)]
395 let cap = self.config.max_candidates_per_group as i64;
396
397 let deleted = zeph_db::query(sql!(
402 "DELETE FROM pending_beliefs
403 WHERE source_entity_id = ?
404 AND canonical_relation = ?
405 AND promoted_at IS NULL
406 AND id NOT IN (
407 SELECT id FROM pending_beliefs
408 WHERE source_entity_id = ?
409 AND canonical_relation = ?
410 AND promoted_at IS NULL
411 ORDER BY prob DESC
412 LIMIT ?
413 )"
414 ))
415 .bind(source_entity_id)
416 .bind(canonical_relation)
417 .bind(source_entity_id)
418 .bind(canonical_relation)
419 .bind(cap)
420 .execute(&self.pool)
421 .await?
422 .rows_affected();
423
424 #[allow(clippy::cast_possible_truncation)]
425 Ok(deleted as usize)
426 }
427
428 async fn find_existing(
431 &self,
432 source_entity_id: i64,
433 target_entity_id: i64,
434 canonical_relation: &str,
435 edge_type_str: &str,
436 ) -> Result<Option<BeliefRow>, MemoryError> {
437 let row: Option<BeliefRow> = zeph_db::query_as(sql!(
438 "SELECT id, source_entity_id, target_entity_id, relation, canonical_relation,
439 fact, edge_type, prob, episode_id, created_at, updated_at
440 FROM pending_beliefs
441 WHERE source_entity_id = ?
442 AND target_entity_id = ?
443 AND canonical_relation = ?
444 AND edge_type = ?
445 AND promoted_at IS NULL
446 LIMIT 1"
447 ))
448 .bind(source_entity_id)
449 .bind(target_entity_id)
450 .bind(canonical_relation)
451 .bind(edge_type_str)
452 .fetch_optional(&self.pool)
453 .await?;
454 Ok(row)
455 }
456
457 async fn apply_evidence_update(
458 &self,
459 row: BeliefRow,
460 evidence_prob: f32,
461 episode_id: Option<&str>,
462 ) -> Result<PendingBelief, MemoryError> {
463 let prior_prob = if self.config.belief_decay_rate > 0.0 {
464 let now_secs = now_unix();
465 #[allow(clippy::cast_precision_loss)]
466 let days_elapsed = (now_secs - row.updated_at) as f64 / 86_400.0;
467 time_decayed_prob(
468 row.prob,
469 days_elapsed.max(0.0),
470 self.config.belief_decay_rate,
471 )
472 } else {
473 row.prob
474 };
475
476 let posterior = noisy_or(prior_prob, evidence_prob);
477
478 let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
479 let raw = format!(
480 "UPDATE pending_beliefs
481 SET prob = ?, updated_at = {epoch_now}, episode_id = ?
482 WHERE id = ?"
483 );
484 let sql = zeph_db::rewrite_placeholders(&raw);
485 zeph_db::query(sqlx::AssertSqlSafe(sql))
486 .bind(posterior)
487 .bind(episode_id)
488 .bind(row.id)
489 .execute(&self.pool)
490 .await?;
491
492 zeph_db::query(sql!(
493 "INSERT INTO belief_evidence
494 (belief_id, prior_prob, evidence_prob, posterior_prob, episode_id)
495 VALUES (?, ?, ?, ?, ?)"
496 ))
497 .bind(row.id)
498 .bind(prior_prob)
499 .bind(evidence_prob)
500 .bind(posterior)
501 .bind(episode_id)
502 .execute(&self.pool)
503 .await?;
504
505 belief_from_row(BeliefRow {
506 prob: posterior,
507 updated_at: now_unix(),
508 episode_id: episode_id.map(ToOwned::to_owned),
509 ..row
510 })
511 }
512
513 #[allow(clippy::too_many_arguments)]
514 async fn insert_new_belief(
515 &self,
516 source_entity_id: i64,
517 target_entity_id: i64,
518 relation: &str,
519 canonical_relation: &str,
520 fact: &str,
521 edge_type_str: &str,
522 evidence_prob: f32,
523 episode_id: Option<&str>,
524 ) -> Result<PendingBelief, MemoryError> {
525 let id: i64 = zeph_db::query_scalar(sql!(
526 "INSERT INTO pending_beliefs
527 (source_entity_id, target_entity_id, relation, canonical_relation,
528 fact, edge_type, prob, episode_id)
529 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
530 RETURNING id"
531 ))
532 .bind(source_entity_id)
533 .bind(target_entity_id)
534 .bind(relation)
535 .bind(canonical_relation)
536 .bind(fact)
537 .bind(edge_type_str)
538 .bind(evidence_prob)
539 .bind(episode_id)
540 .fetch_one(&self.pool)
541 .await?;
542
543 let now = now_unix();
544 zeph_db::query(sql!(
545 "INSERT INTO belief_evidence
546 (belief_id, prior_prob, evidence_prob, posterior_prob, episode_id)
547 VALUES (?, ?, ?, ?, ?)"
548 ))
549 .bind(id)
550 .bind(0.0_f32)
551 .bind(evidence_prob)
552 .bind(evidence_prob)
553 .bind(episode_id)
554 .execute(&self.pool)
555 .await?;
556
557 Ok(PendingBelief {
558 id,
559 source_entity_id,
560 target_entity_id,
561 relation: relation.to_owned(),
562 canonical_relation: canonical_relation.to_owned(),
563 fact: fact.to_owned(),
564 edge_type: edge_type_str.parse::<EdgeType>().unwrap_or_default(),
565 prob: evidence_prob,
566 episode_id: episode_id.map(ToOwned::to_owned),
567 created_at: now,
568 updated_at: now,
569 })
570 }
571}
572
573#[derive(sqlx::FromRow)]
576struct BeliefRow {
577 id: i64,
578 source_entity_id: i64,
579 target_entity_id: i64,
580 relation: String,
581 canonical_relation: String,
582 fact: String,
583 edge_type: String,
584 prob: f32,
585 episode_id: Option<String>,
586 created_at: i64,
587 updated_at: i64,
588}
589
590fn belief_from_row(row: BeliefRow) -> Result<PendingBelief, MemoryError> {
591 let edge_type = row.edge_type.parse::<EdgeType>().map_err(|e| {
592 MemoryError::GraphStore(format!("invalid edge_type '{}': {e}", row.edge_type))
593 })?;
594 Ok(PendingBelief {
595 id: row.id,
596 source_entity_id: row.source_entity_id,
597 target_entity_id: row.target_entity_id,
598 relation: row.relation,
599 canonical_relation: row.canonical_relation,
600 fact: row.fact,
601 edge_type,
602 prob: row.prob,
603 episode_id: row.episode_id,
604 created_at: row.created_at,
605 updated_at: row.updated_at,
606 })
607}
608
609fn now_unix() -> i64 {
610 use std::time::{SystemTime, UNIX_EPOCH};
611 #[allow(clippy::cast_possible_wrap)]
612 SystemTime::now()
613 .duration_since(UNIX_EPOCH)
614 .map_or(0, |d| d.as_secs() as i64)
615}
616
617#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn noisy_or_combines_correctly() {
625 let result = noisy_or(0.4, 0.5);
627 assert!((result - 0.7).abs() < 1e-6, "got {result}");
628 }
629
630 #[test]
631 fn noisy_or_is_bounded() {
632 let result = noisy_or(0.9, 0.9);
633 assert!(result < 1.0);
634 assert!(result > 0.9);
635 }
636
637 #[test]
638 fn noisy_or_accumulates_above_threshold() {
639 let mut p = 0.3_f32;
641 for _ in 1..6 {
642 p = noisy_or(p, 0.3);
643 }
644 assert!(p >= 0.85, "accumulated prob {p} did not reach 0.85");
645 }
646
647 #[test]
648 fn time_decayed_prob_reduces_value() {
649 let original = 0.8_f32;
650 let decayed = time_decayed_prob(original, 30.0, 0.01);
651 assert!(decayed < original);
652 assert!(decayed > 0.0);
653 }
654
655 #[test]
656 fn time_decayed_prob_zero_days_unchanged() {
657 let original = 0.7_f32;
658 let decayed = time_decayed_prob(original, 0.0, 0.01);
659 assert!((decayed - original).abs() < 1e-5);
660 }
661
662 #[test]
663 fn time_decayed_prob_zero_rate_unchanged() {
664 let original = 0.6_f32;
665 let decayed = time_decayed_prob(original, 100.0, 0.0);
666 assert!((decayed - original).abs() < 1e-5);
667 }
668
669 #[test]
670 fn time_decayed_prob_stays_in_bounds() {
671 let decayed = time_decayed_prob(0.99, 10_000.0, 1.0);
672 assert!(decayed > 0.0);
673 assert!(decayed < 1.0);
674 }
675
676 #[test]
677 fn belief_mem_config_defaults() {
678 let cfg = BeliefMemConfig::default();
679 assert!(!cfg.enabled);
680 assert!((cfg.min_entry_prob - 0.3).abs() < 1e-6);
681 assert!((cfg.promote_threshold - 0.85).abs() < 1e-6);
682 assert_eq!(cfg.max_candidates_per_group, 10);
683 assert_eq!(cfg.retrieval_top_k, 3);
684 assert!((cfg.belief_decay_rate - 0.01).abs() < 1e-6);
685 }
686}