use super::{ScorerInput, ScorerResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExactMatchConfig {
pub case_sensitive: Option<bool>,
}
pub fn exact_match(input: &ScorerInput, config: &ExactMatchConfig) -> ScorerResult {
let case_sensitive = config.case_sensitive.unwrap_or(true);
let output_str = value_to_string(&input.output);
let expected_str = input
.expected
.as_ref()
.map(value_to_string)
.unwrap_or_default();
let matches = if case_sensitive {
output_str == expected_str
} else {
output_str.to_lowercase() == expected_str.to_lowercase()
};
ScorerResult {
score: if matches { 1.0 } else { 0.0 },
passed: Some(matches),
label: Some(if matches {
"match".into()
} else {
"mismatch".into()
}),
explanation: None,
metadata: None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContainsConfig {
pub pattern: String,
pub case_sensitive: Option<bool>,
}
pub fn contains(input: &ScorerInput, config: &ContainsConfig) -> ScorerResult {
let case_sensitive = config.case_sensitive.unwrap_or(true);
let output_str = value_to_string(&input.output);
let found = if case_sensitive {
output_str.contains(&config.pattern)
} else {
output_str
.to_lowercase()
.contains(&config.pattern.to_lowercase())
};
ScorerResult {
score: if found { 1.0 } else { 0.0 },
passed: Some(found),
label: Some(if found {
"found".into()
} else {
"not_found".into()
}),
explanation: None,
metadata: None,
}
}
pub fn json_valid(input: &ScorerInput) -> ScorerResult {
let valid = match &input.output {
Value::String(s) => serde_json::from_str::<Value>(s).is_ok(),
_ => true, };
ScorerResult {
score: if valid { 1.0 } else { 0.0 },
passed: Some(valid),
label: Some(if valid {
"valid".into()
} else {
"invalid".into()
}),
explanation: None,
metadata: None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegexConfig {
pub pattern: String,
}
pub fn regex_match(input: &ScorerInput, config: &RegexConfig) -> ScorerResult {
let output_str = value_to_string(&input.output);
match regex::Regex::new(&config.pattern) {
Ok(re) => {
let matches = re.is_match(&output_str);
ScorerResult {
score: if matches { 1.0 } else { 0.0 },
passed: Some(matches),
label: Some(if matches {
"match".into()
} else {
"no_match".into()
}),
explanation: None,
metadata: None,
}
}
Err(e) => ScorerResult {
score: 0.0,
passed: Some(false),
label: Some("error".into()),
explanation: Some(format!("Invalid regex: {}", e)),
metadata: None,
},
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LevenshteinConfig {
pub threshold: Option<f64>,
}
pub fn levenshtein(input: &ScorerInput, config: &LevenshteinConfig) -> ScorerResult {
let output_str = value_to_string(&input.output);
let expected_str = input
.expected
.as_ref()
.map(value_to_string)
.unwrap_or_default();
let distance = levenshtein_distance(&output_str, &expected_str);
let max_len = output_str.len().max(expected_str.len());
let similarity = if max_len == 0 {
1.0
} else {
1.0 - (distance as f64 / max_len as f64)
};
let threshold = config.threshold.unwrap_or(0.0);
let passed = similarity >= threshold;
ScorerResult {
score: similarity,
passed: Some(passed),
label: None,
explanation: Some(format!(
"Similarity: {:.2}%, Distance: {}",
similarity * 100.0,
distance
)),
metadata: None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonSchemaConfig {
pub schema: Value,
}
pub fn json_schema(input: &ScorerInput, config: &JsonSchemaConfig) -> ScorerResult {
let parsed: Value = match &input.output {
Value::String(s) => match serde_json::from_str(s) {
Ok(v) => v,
Err(e) => {
return ScorerResult {
score: 0.0,
passed: Some(false),
label: Some("parse_error".into()),
explanation: Some(format!("output is not valid JSON: {e}")),
metadata: None,
};
}
},
v => v.clone(),
};
let validator = match jsonschema::validator_for(&config.schema) {
Ok(v) => v,
Err(e) => {
return ScorerResult {
score: 0.0,
passed: Some(false),
label: Some("config_error".into()),
explanation: Some(format!("invalid schema: {e}")),
metadata: None,
};
}
};
let errors: Vec<String> = validator
.iter_errors(&parsed)
.map(|e| format!("{}: {}", e.instance_path, e))
.collect();
if errors.is_empty() {
ScorerResult {
score: 1.0,
passed: Some(true),
label: Some("valid".into()),
explanation: None,
metadata: None,
}
} else {
ScorerResult {
score: 0.0,
passed: Some(false),
label: Some("invalid".into()),
explanation: Some(errors[0].clone()),
metadata: Some(serde_json::json!({ "errors": errors })),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NumericRangeConfig {
pub min: Option<f64>,
pub max: Option<f64>,
pub inclusive: Option<bool>,
}
pub fn numeric_range(input: &ScorerInput, config: &NumericRangeConfig) -> ScorerResult {
if config.min.is_none() && config.max.is_none() {
return ScorerResult {
score: 0.0,
passed: Some(false),
label: Some("config_error".into()),
explanation: Some("numeric_range requires at least one of `min` or `max`".into()),
metadata: None,
};
}
let value = match &input.output {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
};
let Some(value) = value else {
return ScorerResult {
score: 0.0,
passed: Some(false),
label: Some("parse_error".into()),
explanation: Some(format!(
"output is not numeric: {}",
serde_json::to_string(&input.output).unwrap_or_default()
)),
metadata: None,
};
};
let inclusive = config.inclusive.unwrap_or(true);
let above_min = match config.min {
Some(min) if inclusive => value >= min,
Some(min) => value > min,
None => true,
};
let below_max = match config.max {
Some(max) if inclusive => value <= max,
Some(max) => value < max,
None => true,
};
let in_range = above_min && below_max;
ScorerResult {
score: if in_range { 1.0 } else { 0.0 },
passed: Some(in_range),
label: Some(if in_range { "in_range" } else { "out_of_range" }.into()),
explanation: Some(format!(
"value={value}, min={:?}, max={:?}, inclusive={inclusive}",
config.min, config.max
)),
metadata: None,
}
}
fn value_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
_ => v.to_string(),
}
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let m = a_chars.len();
let n = b_chars.len();
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let mut prev_row: Vec<usize> = (0..=n).collect();
let mut curr_row: Vec<usize> = vec![0; n + 1];
for i in 1..=m {
curr_row[0] = i;
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
curr_row[j] = (prev_row[j] + 1)
.min(curr_row[j - 1] + 1)
.min(prev_row[j - 1] + cost);
}
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[n]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_exact_match() {
let input = ScorerInput::new(json!("hello")).with_expected(json!("hello"));
let result = exact_match(&input, &ExactMatchConfig::default());
assert_eq!(result.score, 1.0);
assert_eq!(result.passed, Some(true));
let input = ScorerInput::new(json!("hello")).with_expected(json!("world"));
let result = exact_match(&input, &ExactMatchConfig::default());
assert_eq!(result.score, 0.0);
assert_eq!(result.passed, Some(false));
let input = ScorerInput::new(json!("HELLO")).with_expected(json!("hello"));
let config = ExactMatchConfig {
case_sensitive: Some(false),
};
let result = exact_match(&input, &config);
assert_eq!(result.score, 1.0);
}
#[test]
fn test_contains() {
let input = ScorerInput::new(json!("hello world"));
let config = ContainsConfig {
pattern: "world".into(),
case_sensitive: Some(true),
};
let result = contains(&input, &config);
assert_eq!(result.score, 1.0);
let config = ContainsConfig {
pattern: "foo".into(),
case_sensitive: Some(true),
};
let result = contains(&input, &config);
assert_eq!(result.score, 0.0);
let config = ContainsConfig {
pattern: "WORLD".into(),
case_sensitive: Some(false),
};
let result = contains(&input, &config);
assert_eq!(result.score, 1.0);
}
#[test]
fn test_json_valid() {
let input = ScorerInput::new(json!(r#"{"key": "value"}"#));
let result = json_valid(&input);
assert_eq!(result.score, 1.0);
let input = ScorerInput::new(json!("{invalid}"));
let result = json_valid(&input);
assert_eq!(result.score, 0.0);
let input = ScorerInput::new(json!({"key": "value"}));
let result = json_valid(&input);
assert_eq!(result.score, 1.0);
}
#[test]
fn test_regex_match() {
let input = ScorerInput::new(json!("hello123world"));
let config = RegexConfig {
pattern: r"\d+".into(),
};
let result = regex_match(&input, &config);
assert_eq!(result.score, 1.0);
let input = ScorerInput::new(json!("helloworld"));
let result = regex_match(&input, &config);
assert_eq!(result.score, 0.0);
let config = RegexConfig {
pattern: r"[".into(),
};
let result = regex_match(&input, &config);
assert_eq!(result.score, 0.0);
assert_eq!(result.label, Some("error".into()));
}
#[test]
fn test_levenshtein() {
let input = ScorerInput::new(json!("hello")).with_expected(json!("hello"));
let result = levenshtein(&input, &LevenshteinConfig::default());
assert_eq!(result.score, 1.0);
let input = ScorerInput::new(json!("hello")).with_expected(json!("hallo"));
let result = levenshtein(&input, &LevenshteinConfig::default());
assert!(result.score > 0.7);
let config = LevenshteinConfig {
threshold: Some(0.9),
};
let result = levenshtein(&input, &config);
assert_eq!(result.passed, Some(false)); }
#[test]
fn test_json_schema_valid_object() {
let schema = json!({
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string"}}
});
let input = ScorerInput::new(json!({"name": "Ada"}));
let result = json_schema(&input, &JsonSchemaConfig { schema });
assert_eq!(result.score, 1.0);
assert_eq!(result.label.as_deref(), Some("valid"));
}
#[test]
fn test_json_schema_invalid_object() {
let schema = json!({
"type": "object",
"required": ["name", "age"],
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0}
}
});
let input = ScorerInput::new(json!({"name": "Ada", "age": -5}));
let result = json_schema(&input, &JsonSchemaConfig { schema });
assert_eq!(result.score, 0.0);
assert!(result.explanation.is_some());
}
#[test]
fn test_json_schema_string_parses_json() {
let schema = json!({"type": "array", "items": {"type": "integer"}});
let input = ScorerInput::new(json!("[1, 2, 3]"));
let result = json_schema(&input, &JsonSchemaConfig { schema });
assert_eq!(result.score, 1.0);
}
#[test]
fn test_json_schema_invalid_schema_is_config_error() {
let input = ScorerInput::new(json!({"x": 1}));
let result = json_schema(
&input,
&JsonSchemaConfig {
schema: json!("not a schema"),
},
);
assert_eq!(result.label.as_deref(), Some("config_error"));
}
#[test]
fn test_numeric_range_basics() {
let cfg = NumericRangeConfig {
min: Some(0.0),
max: Some(10.0),
inclusive: None,
};
assert_eq!(numeric_range(&ScorerInput::new(json!(5)), &cfg).score, 1.0);
assert_eq!(numeric_range(&ScorerInput::new(json!(0)), &cfg).score, 1.0);
assert_eq!(numeric_range(&ScorerInput::new(json!(10)), &cfg).score, 1.0);
assert_eq!(numeric_range(&ScorerInput::new(json!(-1)), &cfg).score, 0.0);
assert_eq!(numeric_range(&ScorerInput::new(json!(11)), &cfg).score, 0.0);
}
#[test]
fn test_numeric_range_exclusive() {
let cfg = NumericRangeConfig {
min: Some(0.0),
max: Some(10.0),
inclusive: Some(false),
};
assert_eq!(numeric_range(&ScorerInput::new(json!(0)), &cfg).score, 0.0);
assert_eq!(numeric_range(&ScorerInput::new(json!(10)), &cfg).score, 0.0);
assert_eq!(numeric_range(&ScorerInput::new(json!(5)), &cfg).score, 1.0);
}
#[test]
fn test_numeric_range_one_sided() {
let cfg = NumericRangeConfig {
min: Some(100.0),
max: None,
inclusive: None,
};
assert_eq!(numeric_range(&ScorerInput::new(json!(99)), &cfg).score, 0.0);
assert_eq!(
numeric_range(&ScorerInput::new(json!(100)), &cfg).score,
1.0
);
assert_eq!(
numeric_range(&ScorerInput::new(json!(1_000_000)), &cfg).score,
1.0
);
let cfg = NumericRangeConfig {
min: None,
max: Some(0.5),
inclusive: None,
};
assert_eq!(
numeric_range(&ScorerInput::new(json!(0.4)), &cfg).score,
1.0
);
assert_eq!(
numeric_range(&ScorerInput::new(json!(0.5)), &cfg).score,
1.0
);
assert_eq!(
numeric_range(&ScorerInput::new(json!(0.6)), &cfg).score,
0.0
);
}
#[test]
fn test_numeric_range_requires_a_bound() {
let cfg = NumericRangeConfig::default();
let r = numeric_range(&ScorerInput::new(json!(1)), &cfg);
assert_eq!(r.label.as_deref(), Some("config_error"));
}
#[test]
fn test_levenshtein_distance() {
assert_eq!(levenshtein_distance("", ""), 0);
assert_eq!(levenshtein_distance("hello", "hello"), 0);
assert_eq!(levenshtein_distance("hello", ""), 5);
assert_eq!(levenshtein_distance("", "hello"), 5);
assert_eq!(levenshtein_distance("hello", "hallo"), 1);
assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
}
}