use std::sync::OnceLock;
use regex::Regex;
#[derive(Debug, Clone)]
pub struct Groundedness {
pub grounded: bool,
pub unmatched: Vec<String>,
pub exempt_small_ints: Vec<String>,
pub percent_fallback_only: 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();
let mut exempt_small_ints = Vec::new();
let mut percent_fallback_only = 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
};
let display = if negated {
format!("-{raw}")
} else {
raw.to_string()
};
if !is_percent && decimals == 0 && value.abs() <= SMALL_INT_EXEMPTION {
exempt_small_ints.push(display);
continue;
}
let direct = fact_values
.iter()
.any(|&fact| rounds_to(fact, value, decimals));
let fallback = !direct
&& is_percent
&& fact_values
.iter()
.any(|&fact| rounds_to(fact * 100.0, value, decimals));
if fallback {
percent_fallback_only.push(display);
} else if !direct {
unmatched.push(display);
}
}
Groundedness {
grounded: unmatched.is_empty(),
unmatched,
exempt_small_ints,
percent_fallback_only,
}
}
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 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()]);
}
#[test]
fn exempt_small_int_tokens_are_reported() {
let facts: [f64; 0] = [];
let g = check_citations("the 3 files and a delta of -3", &facts);
assert!(g.grounded);
assert_eq!(
g.exempt_small_ints,
vec!["3".to_string(), "-3".to_string()],
"exempt tokens keep appearance order and sign"
);
}
#[test]
fn percent_grounded_only_by_fallback_is_reported() {
let facts = [0.5];
let g = check_citations("about 50% of changes", &facts);
assert!(g.grounded, "unmatched: {:?}", g.unmatched);
assert_eq!(g.percent_fallback_only, vec!["50%".to_string()]);
}
#[test]
fn percent_with_direct_grounding_is_not_fallback_only() {
let facts = [50.0, 0.5];
let g = check_citations("about 50% of changes", &facts);
assert!(g.grounded, "unmatched: {:?}", g.unmatched);
assert!(g.percent_fallback_only.is_empty());
}
#[test]
fn unmatched_percent_token_is_not_fallback_only() {
let facts: [f64; 0] = [];
let g = check_citations("about 99.5%", &facts);
assert!(!g.grounded);
assert!(g.percent_fallback_only.is_empty());
}
#[test]
fn clean_narrative_reports_no_citation_diagnostics() {
let facts = [87.5];
let g = check_citations("Health score 87.5.", &facts);
assert!(g.exempt_small_ints.is_empty());
assert!(g.percent_fallback_only.is_empty());
}
}