Skip to main content

pictor_eval/
meteor.rs

1//! METEOR (lexical subset) — exact-match only.
2//!
3//! This is Denkowski & Lavie (2014)'s METEOR, restricted to exact word
4//! matching: **no stemming, no synonymy (WordNet), no paraphrase tables**.
5//! This limits absolute scores relative to a full METEOR implementation but
6//! preserves the core algorithmic shape:
7//!
8//! 1. Align candidate tokens to reference tokens (exact match, greedy).
9//! 2. Compute precision `P` and recall `R` over matched tokens.
10//! 3. Combine via harmonic-mean with α=0.9 default:
11//!    `F = P·R / (α·P + (1-α)·R)`
12//!    (α→1 weights recall, α→0 weights precision).
13//! 4. Apply fragmentation penalty on the number of *chunks* of consecutive
14//!    matches in the candidate that align to consecutive reference positions:
15//!    `pen = γ · (chunks / matches)^β`  (γ=0.5, β=3 by default).
16//! 5. Final score: `score = (1 - pen) · F`.
17//!
18//! Multi-reference: we compute METEOR against each reference and take the
19//! maximum (standard protocol).
20//!
21//! The limitation is documented here for transparency; if stemming/synonymy
22//! is needed in the future, it can be layered on top of [`align_tokens`].
23
24use crate::rouge::{tokenize, TokenSeq};
25
26/// METEOR score breakdown.
27#[derive(Debug, Clone)]
28pub struct MeteorScore {
29    /// Final METEOR in `[0, 1]`.
30    pub score: f32,
31    /// Matcher precision `matches / |candidate|`.
32    pub precision: f32,
33    /// Matcher recall `matches / |reference|`.
34    pub recall: f32,
35    /// Fragmentation factor in `[0, γ]` (higher = more fragmented).
36    pub fragmentation: f32,
37}
38
39/// METEOR configuration.
40#[derive(Debug, Clone)]
41pub struct MeteorConfig {
42    /// Weight for precision vs recall in the F-mean. Standard α = 0.9.
43    pub alpha: f32,
44    /// Penalty weight for fragmentation (γ, default 0.5).
45    pub gamma: f32,
46    /// Fragmentation exponent (β, default 3.0).
47    pub beta: f32,
48}
49
50impl Default for MeteorConfig {
51    fn default() -> Self {
52        Self {
53            alpha: 0.9,
54            gamma: 0.5,
55            beta: 3.0,
56        }
57    }
58}
59
60/// Compute METEOR between a candidate and a single reference.
61pub fn meteor(candidate: &str, reference: &str, cfg: &MeteorConfig) -> MeteorScore {
62    let cand = tokenize(candidate);
63    let refs = tokenize(reference);
64    meteor_tokens(&cand, &refs, cfg)
65}
66
67/// Compute METEOR from pre-tokenised sequences.
68pub fn meteor_tokens(
69    candidate: &TokenSeq,
70    reference: &TokenSeq,
71    cfg: &MeteorConfig,
72) -> MeteorScore {
73    if candidate.is_empty() && reference.is_empty() {
74        return MeteorScore {
75            score: 1.0,
76            precision: 1.0,
77            recall: 1.0,
78            fragmentation: 0.0,
79        };
80    }
81    if candidate.is_empty() || reference.is_empty() {
82        return MeteorScore {
83            score: 0.0,
84            precision: 0.0,
85            recall: 0.0,
86            fragmentation: 0.0,
87        };
88    }
89
90    // Align tokens: list of (cand_idx, ref_idx) pairs.
91    let alignment = align_tokens(candidate, reference);
92    let matches = alignment.len();
93
94    if matches == 0 {
95        return MeteorScore {
96            score: 0.0,
97            precision: 0.0,
98            recall: 0.0,
99            fragmentation: 0.0,
100        };
101    }
102
103    let p = matches as f32 / candidate.len() as f32;
104    let r = matches as f32 / reference.len() as f32;
105
106    let denom = cfg.alpha * p + (1.0 - cfg.alpha) * r;
107    let f_mean = if denom > 0.0 { (p * r) / denom } else { 0.0 };
108
109    // Count chunks in the alignment (consecutive in both candidate and reference).
110    let chunks = count_chunks(&alignment);
111    let frag = (chunks as f32) / (matches as f32);
112    let pen = cfg.gamma * frag.powf(cfg.beta);
113
114    let score = ((1.0 - pen) * f_mean).clamp(0.0, 1.0);
115    MeteorScore {
116        score,
117        precision: p,
118        recall: r,
119        fragmentation: pen,
120    }
121}
122
123/// Compute METEOR against multiple references; returns max.
124pub fn meteor_multi(candidate: &str, references: &[&str], cfg: &MeteorConfig) -> MeteorScore {
125    if references.is_empty() {
126        return MeteorScore {
127            score: 0.0,
128            precision: 0.0,
129            recall: 0.0,
130            fragmentation: 0.0,
131        };
132    }
133    let mut best: Option<MeteorScore> = None;
134    for r in references {
135        let s = meteor(candidate, r, cfg);
136        best = match best.take() {
137            None => Some(s),
138            Some(b) => {
139                if s.score > b.score {
140                    Some(s)
141                } else {
142                    Some(b)
143                }
144            }
145        };
146    }
147    best.unwrap_or(MeteorScore {
148        score: 0.0,
149        precision: 0.0,
150        recall: 0.0,
151        fragmentation: 0.0,
152    })
153}
154
155// ──────────────────────────────────────────────────────────────────────────────
156// Internal — alignment + chunks
157// ──────────────────────────────────────────────────────────────────────────────
158
159/// Greedy left-to-right alignment: for each candidate token (in order), match
160/// it to the first unclaimed reference position whose token equals it.
161///
162/// Returns `(cand_idx, ref_idx)` pairs in candidate order.
163pub fn align_tokens(candidate: &TokenSeq, reference: &TokenSeq) -> Vec<(usize, usize)> {
164    let mut used = vec![false; reference.len()];
165    let mut out: Vec<(usize, usize)> = Vec::new();
166
167    for (ci, ctok) in candidate.iter().enumerate() {
168        for (ri, rtok) in reference.iter().enumerate() {
169            if !used[ri] && ctok == rtok {
170                used[ri] = true;
171                out.push((ci, ri));
172                break;
173            }
174        }
175    }
176    out
177}
178
179/// Count the number of chunks — maximal runs where both candidate and
180/// reference indices are consecutive.
181///
182/// We sort the alignment by candidate index, then walk it: each pair
183/// continues the current chunk iff `ci == prev_ci + 1 && ri == prev_ri + 1`.
184fn count_chunks(alignment: &[(usize, usize)]) -> usize {
185    if alignment.is_empty() {
186        return 0;
187    }
188    let mut sorted = alignment.to_vec();
189    sorted.sort_by_key(|&(ci, _)| ci);
190
191    let mut chunks = 1usize;
192    for w in sorted.windows(2) {
193        let (pc, pr) = w[0];
194        let (nc, nr) = w[1];
195        if !(nc == pc + 1 && nr == pr + 1) {
196            chunks += 1;
197        }
198    }
199    chunks
200}