1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, error::required_text};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum EvidenceModality {
9 TextSpan,
10 ImageAsset,
11 OcrText,
12 Caption,
13 ImageEmbedding,
14 Table,
15 LayoutRegion,
16}
17
18impl EvidenceModality {
19 pub const fn as_str(self) -> &'static str {
21 match self {
22 Self::TextSpan => "text_span",
23 Self::ImageAsset => "image_asset",
24 Self::OcrText => "ocr_text",
25 Self::Caption => "caption",
26 Self::ImageEmbedding => "image_embedding",
27 Self::Table => "table",
28 Self::LayoutRegion => "layout_region",
29 }
30 }
31
32 pub const fn requires_parent(self) -> bool {
34 matches!(
35 self,
36 Self::OcrText | Self::Caption | Self::ImageEmbedding | Self::LayoutRegion
37 )
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct LayoutRegion {
44 pub page_number: u32,
45 pub x: u32,
46 pub y: u32,
47 pub width: u32,
48 pub height: u32,
49}
50
51impl LayoutRegion {
52 pub fn new(
54 page_number: u32,
55 x: u32,
56 y: u32,
57 width: u32,
58 height: u32,
59 ) -> Result<Self, DomainError> {
60 if page_number == 0 {
61 return Err(DomainError::invalid(
62 "layout_region",
63 "page number must be one-based",
64 ));
65 }
66 if width == 0 || height == 0 {
67 return Err(DomainError::invalid(
68 "layout_region",
69 "width and height must be greater than zero",
70 ));
71 }
72
73 Ok(Self {
74 page_number,
75 x,
76 y,
77 width,
78 height,
79 })
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ExtractionStatus {
87 Succeeded,
88 Degraded,
89 Failed,
90}
91
92impl ExtractionStatus {
93 pub const fn as_str(self) -> &'static str {
95 match self {
96 Self::Succeeded => "succeeded",
97 Self::Degraded => "degraded",
98 Self::Failed => "failed",
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ExtractionDiagnostic {
106 pub status: ExtractionStatus,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub message: Option<String>,
109}
110
111impl ExtractionDiagnostic {
112 pub fn new(status: ExtractionStatus, message: Option<String>) -> Result<Self, DomainError> {
114 let message = message
115 .map(|value| required_text("extraction_diagnostic", value))
116 .transpose()?;
117 if status != ExtractionStatus::Succeeded && message.is_none() {
118 return Err(DomainError::invalid(
119 "extraction_diagnostic",
120 "degraded or failed extraction requires a diagnostic message",
121 ));
122 }
123
124 Ok(Self { status, message })
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct EvidenceExtractionMetadata {
131 pub modality: EvidenceModality,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub source_uri: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub source_hash: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub media_hash: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub extractor: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub extractor_version: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub observed_at: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub parent_evidence_id: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub layout_region: Option<LayoutRegion>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub embedding_model: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub embedding_dimension: Option<u16>,
152 pub diagnostic: ExtractionDiagnostic,
153}
154
155impl EvidenceExtractionMetadata {
156 pub fn text_span() -> Self {
158 Self {
159 modality: EvidenceModality::TextSpan,
160 source_uri: None,
161 source_hash: None,
162 media_hash: None,
163 extractor: None,
164 extractor_version: None,
165 observed_at: None,
166 parent_evidence_id: None,
167 layout_region: None,
168 embedding_model: None,
169 embedding_dimension: None,
170 diagnostic: ExtractionDiagnostic {
171 status: ExtractionStatus::Succeeded,
172 message: None,
173 },
174 }
175 }
176
177 pub fn validate(mut self) -> Result<Self, DomainError> {
179 self.source_uri = normalize_optional_text("source_uri", self.source_uri)?;
180 self.source_hash = normalize_optional_text("source_hash", self.source_hash)?;
181 self.media_hash = normalize_optional_text("media_hash", self.media_hash)?;
182 self.extractor = normalize_optional_text("extractor", self.extractor)?;
183 self.extractor_version =
184 normalize_optional_text("extractor_version", self.extractor_version)?;
185 self.observed_at = normalize_optional_text("observed_at", self.observed_at)?;
186 self.parent_evidence_id =
187 normalize_optional_text("parent_evidence_id", self.parent_evidence_id)?;
188 self.embedding_model = normalize_optional_text("embedding_model", self.embedding_model)?;
189 self.diagnostic =
190 ExtractionDiagnostic::new(self.diagnostic.status, self.diagnostic.message)?;
191 if let Some(region) = self.layout_region {
192 self.layout_region = Some(LayoutRegion::new(
193 region.page_number,
194 region.x,
195 region.y,
196 region.width,
197 region.height,
198 )?);
199 }
200
201 if self.modality.requires_parent() && self.parent_evidence_id.is_none() {
202 return Err(DomainError::invalid(
203 "parent_evidence_id",
204 "derived multimodal evidence must reference a parent evidence item",
205 ));
206 }
207 if self.modality == EvidenceModality::ImageEmbedding
208 && (self.embedding_model.is_none()
209 || self.embedding_dimension.is_none()
210 || self.embedding_dimension == Some(0))
211 {
212 return Err(DomainError::invalid(
213 "embedding_model",
214 "image embedding evidence requires model and positive dimension metadata",
215 ));
216 }
217 if self.modality == EvidenceModality::LayoutRegion && self.layout_region.is_none() {
218 return Err(DomainError::invalid(
219 "layout_region",
220 "layout region evidence requires coordinates",
221 ));
222 }
223 if self.modality == EvidenceModality::ImageAsset
224 && self.media_hash.is_none()
225 && self.source_hash.is_none()
226 {
227 return Err(DomainError::invalid(
228 "media_hash",
229 "image evidence requires a media hash or source hash",
230 ));
231 }
232
233 Ok(self)
234 }
235}
236
237fn normalize_optional_text(
238 field: &'static str,
239 value: Option<String>,
240) -> Result<Option<String>, DomainError> {
241 value.map(|inner| required_text(field, inner)).transpose()
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn validates_derived_multimodal_metadata() {
250 let metadata = EvidenceExtractionMetadata {
251 modality: EvidenceModality::OcrText,
252 parent_evidence_id: Some(" image-1 ".to_owned()),
253 extractor: Some(" tesseract ".to_owned()),
254 extractor_version: Some(" 5.4 ".to_owned()),
255 observed_at: Some(" 2026-05-13T00:00:00Z ".to_owned()),
256 ..EvidenceExtractionMetadata::text_span()
257 }
258 .validate()
259 .expect("metadata should validate");
260
261 assert_eq!(metadata.parent_evidence_id.as_deref(), Some("image-1"));
262 assert_eq!(metadata.extractor.as_deref(), Some("tesseract"));
263 }
264
265 #[test]
266 fn rejects_incomplete_multimodal_metadata() {
267 let derived = EvidenceExtractionMetadata {
268 modality: EvidenceModality::Caption,
269 ..EvidenceExtractionMetadata::text_span()
270 }
271 .validate()
272 .expect_err("derived evidence needs a parent");
273 let failed =
274 ExtractionDiagnostic::new(ExtractionStatus::Failed, None).expect_err("message needed");
275 let layout = EvidenceExtractionMetadata {
276 modality: EvidenceModality::LayoutRegion,
277 parent_evidence_id: Some("page-1".to_owned()),
278 ..EvidenceExtractionMetadata::text_span()
279 }
280 .validate()
281 .expect_err("layout coordinates needed");
282 let invalid_region = EvidenceExtractionMetadata {
283 modality: EvidenceModality::LayoutRegion,
284 parent_evidence_id: Some("page-1".to_owned()),
285 layout_region: Some(LayoutRegion {
286 page_number: 0,
287 x: 0,
288 y: 0,
289 width: 0,
290 height: 1,
291 }),
292 ..EvidenceExtractionMetadata::text_span()
293 }
294 .validate()
295 .expect_err("layout region invariants should be rechecked");
296 let empty_embedding = EvidenceExtractionMetadata {
297 modality: EvidenceModality::ImageEmbedding,
298 parent_evidence_id: Some("image-1".to_owned()),
299 embedding_model: Some("clip".to_owned()),
300 embedding_dimension: Some(0),
301 ..EvidenceExtractionMetadata::text_span()
302 }
303 .validate()
304 .expect_err("zero dimension should be rejected");
305 let degraded_without_message = EvidenceExtractionMetadata {
306 diagnostic: ExtractionDiagnostic {
307 status: ExtractionStatus::Degraded,
308 message: Some(" ".to_owned()),
309 },
310 ..EvidenceExtractionMetadata::text_span()
311 }
312 .validate()
313 .expect_err("diagnostic message invariant should be rechecked");
314
315 assert_eq!(derived.field, "parent_evidence_id");
316 assert_eq!(failed.field, "extraction_diagnostic");
317 assert_eq!(layout.field, "layout_region");
318 assert_eq!(invalid_region.field, "layout_region");
319 assert_eq!(empty_embedding.field, "embedding_model");
320 assert_eq!(degraded_without_message.field, "extraction_diagnostic");
321 }
322
323 #[test]
324 fn validates_layout_and_image_embedding_contracts() {
325 let region = LayoutRegion::new(1, 10, 20, 300, 120).expect("region should validate");
326 let embedding = EvidenceExtractionMetadata {
327 modality: EvidenceModality::ImageEmbedding,
328 parent_evidence_id: Some("image-1".to_owned()),
329 embedding_model: Some("clip-local-hash-v1".to_owned()),
330 embedding_dimension: Some(16),
331 ..EvidenceExtractionMetadata::text_span()
332 }
333 .validate()
334 .expect("embedding metadata should validate");
335
336 assert_eq!(region.width, 300);
337 assert_eq!(embedding.modality.as_str(), "image_embedding");
338 }
339}