use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const DEFAULT_PASS_THRESHOLD: f32 = 7.0;
#[derive(Debug, Clone, PartialEq)]
pub struct RubricCriterion {
pub name: String,
pub weight: f32,
pub description: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Rubric {
pub criteria: Vec<RubricCriterion>,
pub pass_threshold: f32,
pub guidance: Option<String>,
}
impl Rubric {
pub fn total_weight(&self) -> f32 {
self.criteria.iter().map(|c| c.weight).sum()
}
pub fn weighted_total(&self, scores: &[CriterionScore]) -> f32 {
let total_weight = self.total_weight();
if total_weight <= 0.0 {
return 0.0;
}
let mut acc = 0.0;
for c in &self.criteria {
let s = scores
.iter()
.find(|s| s.name.eq_ignore_ascii_case(&c.name))
.map(|s| s.score.clamp(0.0, 10.0))
.unwrap_or(0.0);
acc += s * c.weight;
}
acc / total_weight
}
}
pub fn default_criteria() -> Vec<RubricCriterion> {
vec![
RubricCriterion {
name: "Quality".into(),
weight: 3.0,
description: "Coherence, correctness, and overall execution.".into(),
},
RubricCriterion {
name: "Originality".into(),
weight: 2.0,
description: "Custom, deliberate decisions; avoids generic boilerplate / AI slop."
.into(),
},
RubricCriterion {
name: "Craft".into(),
weight: 2.0,
description: "Attention to detail: naming, structure, edge cases, polish.".into(),
},
RubricCriterion {
name: "Functionality".into(),
weight: 3.0,
description: "Actually works and satisfies the stated goal.".into(),
},
]
}
pub fn default_rubric(pass_threshold: f32) -> Rubric {
Rubric {
criteria: default_criteria(),
pass_threshold,
guidance: None,
}
}
pub fn load_rubric(workspace_root: &str, pass_threshold: f32, path: Option<&str>) -> Rubric {
let rubric_path: PathBuf = match path {
Some(p) => PathBuf::from(p),
None => Path::new(workspace_root)
.join(".albatross")
.join("rubric.md"),
};
let Ok(raw) = std::fs::read_to_string(&rubric_path) else {
return default_rubric(pass_threshold);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return default_rubric(pass_threshold);
}
let parsed = parse_rubric_markdown(trimmed);
let criteria = if parsed.is_empty() {
default_criteria()
} else {
parsed
};
Rubric {
criteria,
pass_threshold,
guidance: Some(trimmed.to_string()),
}
}
fn parse_rubric_markdown(md: &str) -> Vec<RubricCriterion> {
let mut out = Vec::new();
let mut current: Option<(String, f32, String)> = None;
for line in md.lines() {
let trimmed = line.trim();
if let Some((name, weight)) = parse_criterion_heading(trimmed) {
if let Some((n, w, d)) = current.take() {
out.push(RubricCriterion {
name: n,
weight: w,
description: d.trim().to_string(),
});
}
current = Some((name, weight, String::new()));
} else if let Some((_, _, desc)) = current.as_mut() {
if !trimmed.is_empty() {
if !desc.is_empty() {
desc.push(' ');
}
desc.push_str(trimmed);
}
}
}
if let Some((n, w, d)) = current.take() {
out.push(RubricCriterion {
name: n,
weight: w,
description: d.trim().to_string(),
});
}
out
}
fn parse_criterion_heading(line: &str) -> Option<(String, f32)> {
if !line.starts_with('#') {
return None;
}
let after_hash = line.trim_start_matches('#').trim();
const MARKER: &[u8] = b"(weight:";
let open = after_hash
.as_bytes()
.windows(MARKER.len())
.rposition(|w| w.eq_ignore_ascii_case(MARKER))?;
let name = after_hash[..open].trim();
if name.is_empty() {
return None;
}
let rest = &after_hash[open + MARKER.len()..];
let close = rest.find(')')?;
let weight: f32 = rest[..close].trim().parse().ok()?;
if weight <= 0.0 {
return None;
}
Some((name.to_string(), weight))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CriterionScore {
pub name: String,
#[serde(default)]
pub score: f32,
#[serde(default)]
pub justification: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalVerdict {
pub scores: Vec<CriterionScore>,
pub weighted_total: f32,
pub pass: bool,
#[serde(default)]
pub feedback: Vec<String>,
#[serde(default)]
pub verdict: String,
pub parsed: bool,
}
impl EvalVerdict {
pub fn failed(reason: &str) -> Self {
EvalVerdict {
scores: Vec::new(),
weighted_total: 0.0,
pass: false,
feedback: vec![reason.to_string()],
verdict: reason.to_string(),
parsed: false,
}
}
}
#[derive(Deserialize)]
struct RawVerdict {
#[serde(default)]
scores: Vec<CriterionScore>,
#[serde(default)]
feedback: Vec<String>,
#[serde(default)]
verdict: String,
}
pub fn parse_verdict(raw: &str, rubric: &Rubric) -> EvalVerdict {
let Some(json) = extract_json_object(raw) else {
return unparseable_verdict(raw);
};
let Ok(parsed) = serde_json::from_str::<RawVerdict>(&json) else {
return unparseable_verdict(raw);
};
let scores: Vec<CriterionScore> = parsed
.scores
.into_iter()
.map(|s| CriterionScore {
score: s.score.clamp(0.0, 10.0),
name: s.name,
justification: s.justification,
})
.collect();
let weighted_total = rubric.weighted_total(&scores);
EvalVerdict {
pass: weighted_total >= rubric.pass_threshold,
weighted_total,
scores,
feedback: parsed.feedback,
verdict: parsed.verdict,
parsed: true,
}
}
fn unparseable_verdict(raw: &str) -> EvalVerdict {
let snippet: String = raw.trim().chars().take(200).collect();
EvalVerdict {
scores: Vec::new(),
weighted_total: 0.0,
pass: false,
feedback: vec![format!("Evaluator returned unparseable output: {snippet}")],
verdict: "Could not parse evaluator output.".into(),
parsed: false,
}
}
fn extract_json_object(raw: &str) -> Option<String> {
let mut trimmed = raw.trim();
if let Some(rest) = trimmed.strip_prefix("```json") {
trimmed = rest.trim();
} else if let Some(rest) = trimmed.strip_prefix("```") {
trimmed = rest.trim();
}
let trimmed = trimmed.trim_end_matches("```").trim();
if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
return Some(trimmed.to_string());
}
let start = trimmed.find('{')?;
let end = trimmed.rfind('}')?;
if end <= start {
return None;
}
let candidate = &trimmed[start..=end];
serde_json::from_str::<serde_json::Value>(candidate)
.ok()
.map(|_| candidate.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_rubric_has_four_weighted_criteria() {
let r = default_rubric(DEFAULT_PASS_THRESHOLD);
assert_eq!(r.criteria.len(), 4);
assert_eq!(r.total_weight(), 10.0);
assert_eq!(r.pass_threshold, 7.0);
assert!(r
.criteria
.iter()
.any(|c| c.name.eq_ignore_ascii_case("originality")));
}
#[test]
fn weighted_total_normalizes_and_treats_missing_as_zero() {
let r = default_rubric(7.0);
let all_ten: Vec<CriterionScore> = r
.criteria
.iter()
.map(|c| CriterionScore {
name: c.name.clone(),
score: 10.0,
justification: String::new(),
})
.collect();
assert!((r.weighted_total(&all_ten) - 10.0).abs() < 1e-5);
let partial = vec![CriterionScore {
name: "Quality".into(),
score: 10.0,
justification: String::new(),
}];
assert!((r.weighted_total(&partial) - 3.0).abs() < 1e-5);
}
#[test]
fn parse_rubric_markdown_reads_weighted_sections() {
let md = "# My Rubric\n## Taste (weight: 4)\nIs it tasteful?\nmore detail\n## Correctness (weight: 6)\nDoes it work?";
let criteria = parse_rubric_markdown(md);
assert_eq!(criteria.len(), 2);
assert_eq!(criteria[0].name, "Taste");
assert_eq!(criteria[0].weight, 4.0);
assert_eq!(criteria[0].description, "Is it tasteful? more detail");
assert_eq!(criteria[1].name, "Correctness");
assert_eq!(criteria[1].weight, 6.0);
}
#[test]
fn load_rubric_falls_back_to_default_when_missing() {
let dir = tempfile::tempdir().unwrap();
let r = load_rubric(dir.path().to_str().unwrap(), 8.0, None);
assert_eq!(r.criteria, default_criteria());
assert_eq!(r.pass_threshold, 8.0);
assert!(r.guidance.is_none());
}
#[test]
fn load_rubric_reads_custom_file_and_keeps_guidance() {
let dir = tempfile::tempdir().unwrap();
let sh = dir.path().join(".albatross");
std::fs::create_dir_all(&sh).unwrap();
std::fs::write(
sh.join("rubric.md"),
"## Taste (weight: 5)\nTasteful?\n## Rigor (weight: 5)\nRigorous?",
)
.unwrap();
let r = load_rubric(dir.path().to_str().unwrap(), 6.5, None);
assert_eq!(r.criteria.len(), 2);
assert_eq!(r.pass_threshold, 6.5);
assert!(r.guidance.as_deref().unwrap().contains("Taste"));
}
#[test]
fn parse_verdict_computes_total_and_pass_from_rubric() {
let r = default_rubric(7.0);
let raw = r#"{"scores":[
{"name":"Quality","score":8},
{"name":"Originality","score":7},
{"name":"Craft","score":7},
{"name":"Functionality","score":9}
],"feedback":["tighten naming"],"verdict":"solid"}"#;
let v = parse_verdict(raw, &r);
assert!(v.parsed);
assert!((v.weighted_total - 7.9).abs() < 1e-4);
assert!(v.pass);
assert_eq!(v.feedback, vec!["tighten naming".to_string()]);
}
#[test]
fn parse_verdict_ignores_model_reported_total_and_handles_fences() {
let r = default_rubric(7.0);
let raw = "```json\n{\"scores\":[{\"name\":\"Quality\",\"score\":2},{\"name\":\"Functionality\",\"score\":1}],\"weighted_total\":10,\"pass\":true,\"feedback\":[\"broken\"],\"verdict\":\"bad\"}\n```";
let v = parse_verdict(raw, &r);
assert!(v.parsed);
assert!(v.weighted_total < 7.0);
assert!(!v.pass);
}
#[test]
fn parse_verdict_falls_back_when_unparseable() {
let r = default_rubric(7.0);
let v = parse_verdict("the work looks pretty good to me!", &r);
assert!(!v.parsed);
assert!(!v.pass);
assert_eq!(v.weighted_total, 0.0);
assert!(v.feedback[0].contains("unparseable"));
}
}