use std::path::{Path, PathBuf};
use clawft_platform::fs::FileSystem;
pub const HALLUCINATION_EMA_ALPHA: f32 = 0.3;
pub const MAX_HALLUCINATION_BOOST: f32 = 0.5;
pub const HALLUCINATION_SCORE_KEY: &str = "hallucination_score";
const WRITE_TOOL_NAMES: &[&str] = &[
"write_file",
"edit_file",
"create_file",
"write",
"edit",
"save_file",
"patch_file",
];
#[derive(Debug, Clone)]
pub struct VerificationResult {
pub tool_call_id: String,
pub claimed_path: PathBuf,
pub verified: bool,
}
pub fn parse_write_claim(
tool_name: &str,
result_json: &str,
) -> (bool, Option<String>) {
let name_lower = tool_name.to_lowercase();
let is_write = WRITE_TOOL_NAMES.iter().any(|w| name_lower.contains(w));
if !is_write {
return (false, None);
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(result_json) {
for key in &["path", "file_path", "file", "filename"] {
if let Some(p) = val.get(key).and_then(|v| v.as_str())
&& !p.is_empty()
{
return (true, Some(p.to_string()));
}
}
if let Some(inner) = val.get("result") {
for key in &["path", "file_path", "file"] {
if let Some(p) = inner.get(key).and_then(|v| v.as_str())
&& !p.is_empty()
{
return (true, Some(p.to_string()));
}
}
}
}
if let Some(idx) = result_json.find(" to ") {
let after = &result_json[idx + 4..];
let path_str = after
.trim_matches(|c: char| c == '"' || c == '\'' || c == '`')
.split(['"', '\n'])
.next()
.unwrap_or("")
.trim();
if !path_str.is_empty() && path_str.len() < 512 {
return (true, Some(path_str.to_string()));
}
}
(true, None)
}
pub async fn verify_write_results(
fs: &dyn FileSystem,
workspace: &Path,
tool_results: &[(String, String, String)], ) -> Vec<VerificationResult> {
let mut results = Vec::new();
for (id, name, result_json) in tool_results {
let (is_write, maybe_path) = parse_write_claim(name, result_json);
if !is_write {
continue;
}
if let Some(ref path_str) = maybe_path {
let full_path = if Path::new(path_str).is_absolute() {
PathBuf::from(path_str)
} else {
workspace.join(path_str)
};
let verified = fs.exists(&full_path).await;
results.push(VerificationResult {
tool_call_id: id.clone(),
claimed_path: full_path,
verified,
});
}
}
results
}
pub fn update_hallucination_score(
current: f32,
hallucinations: usize,
successes: usize,
alpha: f32,
) -> f32 {
let total = hallucinations + successes;
if total == 0 {
return current;
}
let batch_rate = hallucinations as f32 / total as f32;
let new_score = alpha * batch_rate + (1.0 - alpha) * current;
new_score.clamp(0.0, 1.0)
}
pub fn score_to_boost(score: f32) -> f32 {
(score * MAX_HALLUCINATION_BOOST).min(MAX_HALLUCINATION_BOOST)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_write_claim_detects_write_file() {
let (is_write, path) = parse_write_claim(
"write_file",
r#"{"path": "src/main.rs"}"#,
);
assert!(is_write);
assert_eq!(path.as_deref(), Some("src/main.rs"));
}
#[test]
fn parse_write_claim_detects_edit_file() {
let (is_write, path) = parse_write_claim(
"edit_file",
r#"{"file_path": "lib.rs"}"#,
);
assert!(is_write);
assert_eq!(path.as_deref(), Some("lib.rs"));
}
#[test]
fn parse_write_claim_extracts_from_text() {
let (is_write, path) = parse_write_claim(
"write_file",
r#""Successfully wrote 42 bytes to src/app.rs""#,
);
assert!(is_write);
assert_eq!(path.as_deref(), Some("src/app.rs"));
}
#[test]
fn parse_write_claim_ignores_non_write_tools() {
let (is_write, _) = parse_write_claim(
"web_search",
r#"{"path": "something"}"#,
);
assert!(!is_write);
}
#[test]
fn parse_write_claim_write_tool_no_path() {
let (is_write, path) = parse_write_claim(
"write_file",
r#"{"status": "ok"}"#,
);
assert!(is_write);
assert!(path.is_none());
}
#[test]
fn ema_first_hallucination_from_zero() {
let score = update_hallucination_score(0.0, 1, 0, HALLUCINATION_EMA_ALPHA);
assert!((score - 0.3).abs() < f32::EPSILON, "score={score}");
}
#[test]
fn ema_second_hallucination() {
let s1 = update_hallucination_score(0.0, 1, 0, HALLUCINATION_EMA_ALPHA);
let s2 = update_hallucination_score(s1, 1, 0, HALLUCINATION_EMA_ALPHA);
assert!((s2 - 0.51).abs() < 0.001, "score={s2}");
}
#[test]
fn ema_success_decays_score() {
let s1 = update_hallucination_score(0.5, 0, 1, HALLUCINATION_EMA_ALPHA);
assert!((s1 - 0.35).abs() < 0.001, "score={s1}");
}
#[test]
fn ema_no_verifications_returns_current() {
let score = update_hallucination_score(0.42, 0, 0, HALLUCINATION_EMA_ALPHA);
assert!((score - 0.42).abs() < f32::EPSILON);
}
#[test]
fn ema_clamped_to_unit_range() {
let score = update_hallucination_score(1.0, 1, 0, HALLUCINATION_EMA_ALPHA);
assert!(score >= 0.0 && score <= 1.0, "score={score}");
}
#[test]
fn boost_zero_for_zero_score() {
assert!((score_to_boost(0.0)).abs() < f32::EPSILON);
}
#[test]
fn boost_proportional_to_score() {
let boost = score_to_boost(0.6);
assert!((boost - 0.3).abs() < 0.001, "boost={boost}");
}
#[test]
fn boost_capped_at_max() {
let boost = score_to_boost(1.0);
assert!(
(boost - MAX_HALLUCINATION_BOOST).abs() < f32::EPSILON,
"boost={boost}"
);
}
#[test]
fn boost_capped_above_one() {
let boost = score_to_boost(2.0);
assert!(
boost <= MAX_HALLUCINATION_BOOST,
"boost={boost} should be <= {}",
MAX_HALLUCINATION_BOOST
);
}
#[tokio::test]
async fn verify_existing_file() {
let dir = std::env::temp_dir().join("clawft_verify_test");
let _ = tokio::fs::create_dir_all(&dir).await;
let file = dir.join("exists.txt");
tokio::fs::write(&file, "hello").await.unwrap();
let native_fs = clawft_platform::fs::NativeFileSystem;
let results = verify_write_results(
&native_fs,
&dir,
&[(
"call-1".into(),
"write_file".into(),
format!(r#"{{"path": "exists.txt"}}"#),
)],
)
.await;
assert_eq!(results.len(), 1);
assert!(results[0].verified, "existing file should verify");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn verify_missing_file_detects_hallucination() {
let dir = std::env::temp_dir().join("clawft_verify_miss");
let _ = tokio::fs::create_dir_all(&dir).await;
let native_fs = clawft_platform::fs::NativeFileSystem;
let results = verify_write_results(
&native_fs,
&dir,
&[(
"call-2".into(),
"write_file".into(),
r#"{"path": "ghost.txt"}"#.into(),
)],
)
.await;
assert_eq!(results.len(), 1);
assert!(
!results[0].verified,
"missing file should NOT verify (hallucination)"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn verify_skips_non_write_tools() {
let native_fs = clawft_platform::fs::NativeFileSystem;
let results = verify_write_results(
&native_fs,
Path::new("/tmp"),
&[(
"call-3".into(),
"web_search".into(),
r#"{"path": "anything"}"#.into(),
)],
)
.await;
assert!(results.is_empty(), "non-write tools should be skipped");
}
}