use std::sync::OnceLock;
use regex::Regex;
#[derive(Debug, Clone)]
pub struct Groundedness {
pub grounded: bool,
pub unmatched: Vec<String>,
}
const SMALL_INT_EXEMPTION: f64 = 12.0;
#[must_use]
pub fn check_citations(narrative: &str, fact_values: &[f64]) -> Groundedness {
let stripped = strip_thousands_separators(narrative);
let mut unmatched = Vec::new();
for token in token_regex().find_iter(&stripped) {
let raw = token.as_str();
let is_percent = raw.ends_with('%');
let digits = raw.trim_end_matches('%');
let decimals = digits.split_once('.').map_or(0, |(_, frac)| frac.len());
let Ok(unsigned_value) = digits.parse::<f64>() else {
continue;
};
let negated = is_unary_minus(&stripped, token.start());
let value = if negated {
-unsigned_value
} else {
unsigned_value
};
if !is_percent && decimals == 0 && value.abs() <= SMALL_INT_EXEMPTION {
continue;
}
let matched = fact_values
.iter()
.any(|&fact| matches_at(fact, value, decimals, is_percent));
if !matched {
let display = if negated {
format!("-{raw}")
} else {
raw.to_string()
};
unmatched.push(display);
}
}
Groundedness {
grounded: unmatched.is_empty(),
unmatched,
}
}
fn is_unary_minus(text: &str, token_start: usize) -> bool {
let prefix = &text[..token_start];
let mut chars = prefix.chars().rev();
let Some(prev) = chars.next() else {
return false;
};
if prev != '-' {
return false;
}
match chars.next() {
Some(prev2) => !prev2.is_alphanumeric(),
None => true,
}
}
fn matches_at(fact: f64, token_value: f64, decimals: usize, is_percent: bool) -> bool {
rounds_to(fact, token_value, decimals)
|| (is_percent && rounds_to(fact * 100.0, token_value, decimals))
}
fn rounds_to(value: f64, target: f64, decimals: usize) -> bool {
let factor: f64 = (0..decimals).fold(1.0, |acc, _| acc * 10.0);
let scaled = (value * factor).round();
let expected = (target * factor).round();
(scaled - expected).abs() < 0.5
}
fn token_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?:\d+\.\d+|\d+)(?:%)?").unwrap())
}
fn thousands_sep_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(\d),(\d)").unwrap())
}
fn strip_thousands_separators(text: &str) -> String {
let mut out = text.to_string();
loop {
let replaced = thousands_sep_regex().replace_all(&out, "$1$2").into_owned();
if replaced == out {
return out;
}
out = replaced;
}
}
#[cfg(test)]
mod tests {
use super::check_citations;
#[test]
fn fully_grounded_narrative_has_no_unmatched() {
let facts = [87.5, 0.803];
let g = check_citations("Health score 87.5 with coupling 0.803.", &facts);
assert!(g.grounded);
assert!(g.unmatched.is_empty());
}
#[test]
fn one_invented_number_is_listed_unmatched() {
let facts = [0.786, 0.803];
let g = check_citations(
"Grounded 0.786 and 0.803, but invented 42.5 appears too.",
&facts,
);
assert!(!g.grounded);
assert_eq!(g.unmatched, vec!["42.5".to_string()]);
}
#[test]
fn rounded_citation_is_grounded() {
let facts = [0.786];
let g = check_citations("about 0.79 coupling", &facts);
assert!(g.grounded, "unmatched: {:?}", g.unmatched);
}
#[test]
fn percent_citation_matches_fraction() {
let facts = [0.803];
let g = check_citations("roughly 80% of changes", &facts);
assert!(g.grounded, "unmatched: {:?}", g.unmatched);
}
#[test]
fn small_whole_numbers_are_exempt() {
let facts: [f64; 0] = [];
let g = check_citations("the 3 files in this module", &facts);
assert!(g.grounded);
assert!(g.unmatched.is_empty());
}
#[test]
fn empty_narrative_is_grounded() {
let facts = [1.0, 2.0];
let g = check_citations("", &facts);
assert!(g.grounded);
}
#[test]
fn zero_matches_subepsilon_fact_value() {
let facts = [1e-9];
let g = check_citations("effectively 0 signal here", &facts);
assert!(g.grounded);
}
#[test]
fn thousands_separator_is_stripped_before_matching() {
let facts = [1234.0];
let g = check_citations("touched 1,234 lines", &facts);
assert!(g.grounded, "unmatched: {:?}", g.unmatched);
}
#[test]
fn large_uncited_whole_number_is_flagged() {
let facts = [3.0];
let g = check_citations("spanning 4200 revisions", &facts);
assert!(!g.grounded);
assert_eq!(g.unmatched, vec!["4200".to_string()]);
}
#[test]
fn signed_token_mismatching_positive_fact_is_flagged() {
let facts = [0.5];
let g = check_citations("a delta of -0.5", &facts);
assert!(!g.grounded);
assert_eq!(g.unmatched, vec!["-0.5".to_string()]);
}
#[test]
fn signed_token_matching_negative_fact_is_grounded() {
let facts = [-420.7];
let g = check_citations("MI of -420.7", &facts);
assert!(g.grounded, "unmatched: {:?}", g.unmatched);
}
#[test]
fn positive_token_does_not_match_negative_fact() {
let facts = [-0.5];
let g = check_citations("a value of 0.5", &facts);
assert!(!g.grounded);
assert_eq!(g.unmatched, vec!["0.5".to_string()]);
}
#[test]
fn hyphenated_date_fragments_stay_unsigned() {
let facts: [f64; 0] = [];
let g = check_citations("vintage defects-2026-07-15", &facts);
assert!(!g.grounded);
assert_eq!(g.unmatched, vec!["2026".to_string(), "15".to_string()]);
assert!(
g.unmatched.iter().all(|tok| !tok.starts_with('-')),
"hyphenated date/vintage fragments must never read as negative: {:?}",
g.unmatched
);
}
#[test]
fn negative_small_int_is_exempt() {
let facts: [f64; 0] = [];
let g = check_citations("a delta of -3", &facts);
assert!(g.grounded, "unmatched: {:?}", g.unmatched);
}
#[test]
fn negative_large_int_is_not_exempt() {
let facts: [f64; 0] = [];
let g = check_citations("a delta of -15", &facts);
assert!(!g.grounded);
assert_eq!(g.unmatched, vec!["-15".to_string()]);
}
#[test]
fn unmatched_percent_token_reports_the_percent_sign() {
let facts: [f64; 0] = [];
let g = check_citations("about 99.5%", &facts);
assert_eq!(g.unmatched, vec!["99.5%".to_string()]);
}
}