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        #[schemars(with = "i64")]
28        edit_distance: usize,
29    },
30    RelationContradiction {
31        subject: String,
32        text_claim: String,
33        kg_fact: String,
34        triple_id: String,
35        source_drawer: Option<String>,
36    },
37    StaleFact {
38        subject: String,
39        predicate: String,
40        object: String,
41        /// Unix seconds as stored in DB (triple.valid_to).
42        valid_to: String,
43        triple_id: String,
44    },
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
48pub struct FactCheckReport {
49    pub issues: Vec<FactIssue>,
50    pub checked_entities: Vec<String>,
51    #[schemars(with = "i64")]
52    pub kg_triples_scanned: usize,
53}
54
55#[derive(Debug, Error)]
56pub enum FactCheckError {
57    #[error("db error: {0}")]
58    Db(#[from] DbError),
59    #[error("invalid scope: {0}")]
60    InvalidScope(String),
61    #[error("invalid `now`: {0}")]
62    InvalidNow(String),
63}
64
65pub fn validate_scope<'a>(
66    wing: Option<&'a str>,
67    room: Option<&'a str>,
68) -> Result<Option<(&'a str, Option<&'a str>)>, FactCheckError> {
69    match (wing.map(str::trim), room.map(str::trim)) {
70        (None, Some(_)) => Err(FactCheckError::InvalidScope(
71            "room requires wing".to_string(),
72        )),
73        (Some(""), _) => Err(FactCheckError::InvalidScope(
74            "wing must not be empty".to_string(),
75        )),
76        (_, Some("")) => Err(FactCheckError::InvalidScope(
77            "room must not be empty".to_string(),
78        )),
79        (Some(wing), room) => Ok(Some((wing, room))),
80        (None, None) => Ok(None),
81    }
82}
83
84pub fn resolve_now(now: Option<&str>) -> Result<u64, FactCheckError> {
85    match now {
86        Some(raw) => {
87            let ts = crate::cowork::peek::parse_rfc3339(raw).ok_or_else(|| {
88                FactCheckError::InvalidNow(format!("expected RFC3339 timestamp, got `{raw}`"))
89            })?;
90            u64::try_from(ts).map_err(|_| {
91                FactCheckError::InvalidNow(format!(
92                    "timestamp before Unix epoch is unsupported: {raw}"
93                ))
94            })
95        }
96        None => {
97            use std::time::{SystemTime, UNIX_EPOCH};
98            Ok(SystemTime::now()
99                .duration_since(UNIX_EPOCH)
100                .map(|d| d.as_secs())
101                .unwrap_or(0))
102        }
103    }
104}
105
106/// Run fact check against the KG.
107///
108/// `now_unix_secs`: Unix seconds for the "now" cutoff used by StaleFact
109/// detection. Matches the KG storage convention (`valid_to` is text Unix
110/// seconds). Callers should use `crate::core::utils::current_timestamp()`
111/// to obtain the current value.
112///
113/// `scope`: optional `(wing, room)` filter for which drawers contribute
114/// to the known-entity set. `None` = all wings.
115pub fn check(
116    text: &str,
117    db: &Database,
118    now_unix_secs: u64,
119    scope: Option<(&str, Option<&str>)>,
120) -> Result<FactCheckReport, FactCheckError> {
121    let scope = match scope {
122        Some((wing, room)) => validate_scope(Some(wing), room)?,
123        None => None,
124    };
125    let text_names = names::candidates_from_text(text);
126    let known = names::query_known_entities(db, scope)?;
127    let mut issues = names::detect_similar_name_conflicts(&text_names, &known);
128
129    let text_triples = relations::extract_triples(text);
130    let kg_triples_scanned = db.triple_count().unwrap_or(0) as usize;
131
132    issues.extend(contradictions::detect_relation_contradictions(
133        db,
134        &text_triples,
135    )?);
136    issues.extend(contradictions::detect_stale_facts(
137        db,
138        &text_triples,
139        now_unix_secs,
140    )?);
141
142    Ok(FactCheckReport {
143        issues,
144        checked_entities: text_names,
145        kg_triples_scanned,
146    })
147}