Skip to main content

mempal_runtime/factcheck/
mod.rs

1//! Offline fact-checking against KG triples + entity registry (P9-A).
2//!
3//! Given a text blob, detect three contradiction classes:
4//! 1. SimilarNameConflict — mentioned name ≤2 edit distance from known entity
5//! 2. RelationContradiction — KG has incompatible predicate for same (subject, object)
6//! 3. StaleFact — text asserts a triple whose KG row has valid_to < now
7//!
8//! Zero LLM, zero network, deterministic. Time is Unix seconds (String) to
9//! match the existing KG storage convention (no chrono dep).
10
11use rmcp::schemars::{self, JsonSchema};
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::core::db::{Database, DbError};
16
17pub mod contradictions;
18pub mod names;
19pub mod relations;
20
21#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23pub enum FactIssue {
24    SimilarNameConflict {
25        mentioned: String,
26        known_entity: String,
27        edit_distance: usize,
28    },
29    RelationContradiction {
30        subject: String,
31        text_claim: String,
32        kg_fact: String,
33        triple_id: String,
34        source_drawer: Option<String>,
35    },
36    StaleFact {
37        subject: String,
38        predicate: String,
39        object: String,
40        /// Unix seconds as stored in DB (triple.valid_to).
41        valid_to: String,
42        triple_id: String,
43    },
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
47pub struct FactCheckReport {
48    pub issues: Vec<FactIssue>,
49    pub checked_entities: Vec<String>,
50    pub kg_triples_scanned: usize,
51}
52
53#[derive(Debug, Error)]
54pub enum FactCheckError {
55    #[error("db error: {0}")]
56    Db(#[from] DbError),
57    #[error("invalid scope: {0}")]
58    InvalidScope(String),
59    #[error("invalid `now`: {0}")]
60    InvalidNow(String),
61}
62
63pub fn validate_scope<'a>(
64    wing: Option<&'a str>,
65    room: Option<&'a str>,
66) -> Result<Option<(&'a str, Option<&'a str>)>, FactCheckError> {
67    match (wing.map(str::trim), room.map(str::trim)) {
68        (None, Some(_)) => Err(FactCheckError::InvalidScope(
69            "room requires wing".to_string(),
70        )),
71        (Some(""), _) => Err(FactCheckError::InvalidScope(
72            "wing must not be empty".to_string(),
73        )),
74        (_, Some("")) => Err(FactCheckError::InvalidScope(
75            "room must not be empty".to_string(),
76        )),
77        (Some(wing), room) => Ok(Some((wing, room))),
78        (None, None) => Ok(None),
79    }
80}
81
82pub fn resolve_now(now: Option<&str>) -> Result<u64, FactCheckError> {
83    match now {
84        Some(raw) => {
85            let ts = crate::cowork::peek::parse_rfc3339(raw).ok_or_else(|| {
86                FactCheckError::InvalidNow(format!("expected RFC3339 timestamp, got `{raw}`"))
87            })?;
88            u64::try_from(ts).map_err(|_| {
89                FactCheckError::InvalidNow(format!(
90                    "timestamp before Unix epoch is unsupported: {raw}"
91                ))
92            })
93        }
94        None => {
95            use std::time::{SystemTime, UNIX_EPOCH};
96            Ok(SystemTime::now()
97                .duration_since(UNIX_EPOCH)
98                .map(|d| d.as_secs())
99                .unwrap_or(0))
100        }
101    }
102}
103
104/// Run fact check against the KG.
105///
106/// `now_unix_secs`: Unix seconds for the "now" cutoff used by StaleFact
107/// detection. Matches the KG storage convention (`valid_to` is text Unix
108/// seconds). Callers should use `crate::core::utils::current_timestamp()`
109/// to obtain the current value.
110///
111/// `scope`: optional `(wing, room)` filter for which drawers contribute
112/// to the known-entity set. `None` = all wings.
113pub fn check(
114    text: &str,
115    db: &Database,
116    now_unix_secs: u64,
117    scope: Option<(&str, Option<&str>)>,
118) -> Result<FactCheckReport, FactCheckError> {
119    let scope = match scope {
120        Some((wing, room)) => validate_scope(Some(wing), room)?,
121        None => None,
122    };
123    let text_names = names::candidates_from_text(text);
124    let known = names::query_known_entities(db, scope)?;
125    let mut issues = names::detect_similar_name_conflicts(&text_names, &known);
126
127    let text_triples = relations::extract_triples(text);
128    let kg_triples_scanned = db.triple_count().unwrap_or(0) as usize;
129
130    issues.extend(contradictions::detect_relation_contradictions(
131        db,
132        &text_triples,
133    )?);
134    issues.extend(contradictions::detect_stale_facts(
135        db,
136        &text_triples,
137        now_unix_secs,
138    )?);
139
140    Ok(FactCheckReport {
141        issues,
142        checked_entities: text_names,
143        kg_triples_scanned,
144    })
145}