anno_eval/eval/
validation.rs1use super::datasets::GoldEntity;
10use anno::EntityType;
11use anno::{Error, Result};
12
13#[derive(Debug, Clone)]
15pub struct ValidationResult {
16 pub is_valid: bool,
18 pub errors: Vec<String>,
20 pub warnings: Vec<String>,
22}
23
24impl ValidationResult {
25 #[must_use]
27 pub fn new() -> Self {
28 Self {
29 is_valid: true,
30 errors: Vec::new(),
31 warnings: Vec::new(),
32 }
33 }
34
35 pub fn add_error(&mut self, error: String) {
37 self.is_valid = false;
38 self.errors.push(error);
39 }
40
41 pub fn add_warning(&mut self, warning: String) {
43 self.warnings.push(warning);
44 }
45
46 pub fn into_result(self) -> Result<()> {
48 if self.is_valid {
49 Ok(())
50 } else {
51 Err(Error::InvalidInput(format!(
52 "Validation failed: {}",
53 self.errors.join("; ")
54 )))
55 }
56 }
57}
58
59impl Default for ValidationResult {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65pub fn validate_ground_truth_entities(
81 text: &str,
82 entities: &[GoldEntity],
83 strict: bool,
84) -> ValidationResult {
85 let mut result = ValidationResult::new();
86 let text_char_len = text.chars().count();
88
89 for (i, entity) in entities.iter().enumerate() {
91 if entity.text.trim().is_empty() {
93 result.add_warning(format!(
94 "Entity {}: text is empty or whitespace-only: '{}'",
95 i, entity.text
96 ));
97 }
98
99 if entity.start >= text_char_len {
101 result.add_error(format!(
102 "Entity {}: start position {} out of bounds (text length: {} chars)",
103 i, entity.start, text_char_len
104 ));
105 continue;
106 }
107
108 if entity.end > text_char_len {
109 result.add_error(format!(
110 "Entity {}: end position {} out of bounds (text length: {} chars)",
111 i, entity.end, text_char_len
112 ));
113 continue;
114 }
115
116 if entity.start >= entity.end {
118 result.add_error(format!(
119 "Entity {}: invalid span (start {} >= end {})",
120 i, entity.start, entity.end
121 ));
122 continue;
123 }
124
125 let span_text: String = text
127 .chars()
128 .skip(entity.start)
129 .take(entity.end - entity.start)
130 .collect();
131 if span_text != entity.text {
132 result.add_warning(format!(
133 "Entity {}: text mismatch. Expected '{}', found '{}'",
134 i, entity.text, span_text
135 ));
136 }
137 }
138
139 for i in 0..entities.len() {
141 for j in (i + 1)..entities.len() {
142 let e1 = &entities[i];
143 let e2 = &entities[j];
144
145 let overlap = (e1.start < e2.end) && (e2.start < e1.end);
147 if overlap {
148 let msg = format!(
149 "Entities {} and {} overlap: [{}, {}) and [{}, {})",
150 i, j, e1.start, e1.end, e2.start, e2.end
151 );
152 if strict {
153 result.add_error(msg);
154 } else {
155 result.add_warning(msg);
156 }
157 }
158 }
159 }
160
161 result
162}
163
164pub fn validate_entity_type_consistency(
169 test_cases: &[(String, Vec<GoldEntity>)],
170) -> ValidationResult {
171 let mut result = ValidationResult::new();
172 let mut type_map: std::collections::HashMap<String, EntityType> =
173 std::collections::HashMap::new();
174
175 for (case_idx, (_text, entities)) in test_cases.iter().enumerate() {
176 for entity in entities {
177 let type_str = crate::eval::entity_type_to_string(&entity.entity_type);
178 if let Some(existing_type) = type_map.get(&type_str) {
179 if !crate::eval::entity_type_matches(existing_type, &entity.entity_type) {
181 result.add_warning(format!(
182 "Test case {}: Entity type '{}' inconsistent with previous usage",
183 case_idx, type_str
184 ));
185 }
186 } else {
187 type_map.insert(type_str, entity.entity_type.clone());
188 }
189 }
190 }
191
192 result
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn test_validate_bounds() {
201 let text = "Hello world";
202 let entities = vec![GoldEntity {
203 text: "Hello".to_string(),
204 entity_type: EntityType::Person,
205 original_label: "PER".to_string(),
206 start: 0,
207 end: 5,
208 }];
209
210 let result = validate_ground_truth_entities(text, &entities, false);
211 assert!(result.is_valid);
212 }
213
214 #[test]
215 fn test_validate_out_of_bounds() {
216 let text = "Hello";
217 let entities = vec![GoldEntity {
218 text: "world".to_string(),
219 entity_type: EntityType::Person,
220 original_label: "PER".to_string(),
221 start: 10,
222 end: 15,
223 }];
224
225 let result = validate_ground_truth_entities(text, &entities, false);
226 assert!(!result.is_valid);
227 assert!(!result.errors.is_empty());
228 }
229
230 #[test]
231 fn test_validate_overlapping() {
232 let text = "Hello world";
233 let entities = vec![
234 GoldEntity {
235 text: "Hello".to_string(),
236 entity_type: EntityType::Person,
237 original_label: "PER".to_string(),
238 start: 0,
239 end: 5,
240 },
241 GoldEntity {
242 text: "lo wo".to_string(),
243 entity_type: EntityType::Person,
244 original_label: "PER".to_string(),
245 start: 3,
246 end: 8,
247 },
248 ];
249
250 let result = validate_ground_truth_entities(text, &entities, false);
251 assert!(result.is_valid); assert!(!result.warnings.is_empty());
253
254 let result_strict = validate_ground_truth_entities(text, &entities, true);
255 assert!(!result_strict.is_valid); }
257}