Skip to main content

relay_knowledge/domain/
multimodal.rs

1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, error::required_text};
4
5/// Evidence media unit tracked by multimodal ingestion and retrieval.
6#[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    /// Stable storage and API representation.
20    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    /// Returns whether this modality is derived from a parent evidence item.
33    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/// Page-space rectangle for table and layout evidence.
42#[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    /// Validates a one-based page coordinate and non-empty rectangle.
53    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/// Extraction outcome recorded when multimodal workers degrade or fail.
84#[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    /// Stable storage and API representation.
94    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/// Diagnostic emitted by an extractor without blocking other modalities.
104#[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    /// Validates status and requires a useful message for degraded/failed work.
113    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/// Metadata shared by text, image, OCR, caption, table, layout, and embedding evidence.
129#[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    /// Creates validated default text evidence metadata.
157    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    /// Validates extractor metadata and cross-field modality invariants.
178    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}