Skip to main content

relay_knowledge/domain/graph/mutation/
mod.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6    DomainError, EvidenceExtractionMetadata, GraphVersion, SourceScope, error::required_text,
7};
8
9/// Lifecycle status for graph facts created from evidence or extraction.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum FactStatus {
13    Proposed,
14    Accepted,
15    Rejected,
16    Superseded,
17}
18
19impl FactStatus {
20    /// Stable storage and API representation.
21    pub const fn as_str(self) -> &'static str {
22        match self {
23            Self::Proposed => "proposed",
24            Self::Accepted => "accepted",
25            Self::Rejected => "rejected",
26            Self::Superseded => "superseded",
27        }
28    }
29
30    /// Parses the stable storage and API representation.
31    pub fn parse(value: &str) -> Result<Self, DomainError> {
32        match value {
33            "proposed" => Ok(Self::Proposed),
34            "accepted" => Ok(Self::Accepted),
35            "rejected" => Ok(Self::Rejected),
36            "superseded" => Ok(Self::Superseded),
37            _ => Err(DomainError::invalid("fact_status", "unknown fact status")),
38        }
39    }
40}
41
42/// Confidence score in basis points so it remains sortable and exact.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ConfidenceScore {
45    pub basis_points: u16,
46}
47
48impl ConfidenceScore {
49    pub const CERTAIN: Self = Self {
50        basis_points: 10_000,
51    };
52
53    /// Validates a confidence score from 0.0 through 1.0.
54    pub fn from_ratio(value: f32) -> Result<Self, DomainError> {
55        if !(0.0..=1.0).contains(&value) || value.is_nan() {
56            return Err(DomainError::invalid(
57                "confidence",
58                "must be between 0.0 and 1.0",
59            ));
60        }
61
62        Ok(Self {
63            basis_points: (value * 10_000.0).round() as u16,
64        })
65    }
66
67    /// Revalidates scores that may have been deserialized from public fields.
68    pub fn validate(self) -> Result<Self, DomainError> {
69        if self.basis_points > 10_000 {
70            return Err(DomainError::invalid(
71                "confidence",
72                "must be between 0 and 10000 basis points",
73            ));
74        }
75
76        Ok(self)
77    }
78}
79
80/// Source byte and line span for evidence-backed facts.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub struct EvidenceSpan {
83    pub start_byte: u32,
84    pub end_byte: u32,
85    pub start_line: u32,
86    pub end_line: u32,
87}
88
89impl EvidenceSpan {
90    /// Validates a half-open byte span and one-based line coordinates.
91    pub fn new(
92        start_byte: u32,
93        end_byte: u32,
94        start_line: u32,
95        end_line: u32,
96    ) -> Result<Self, DomainError> {
97        if end_byte <= start_byte {
98            return Err(DomainError::invalid(
99                "evidence_span",
100                "end byte must be greater than start byte",
101            ));
102        }
103        if start_line == 0 {
104            return Err(DomainError::invalid(
105                "evidence_span",
106                "start line must be one-based",
107            ));
108        }
109        if end_line < start_line {
110            return Err(DomainError::invalid(
111                "evidence_span",
112                "end line must not be before start line",
113            ));
114        }
115
116        Ok(Self {
117            start_byte,
118            end_byte,
119            start_line,
120            end_line,
121        })
122    }
123}
124
125/// Graph-version validity range for facts.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127pub struct GraphVersionRange {
128    pub valid_from: GraphVersion,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub valid_until: Option<GraphVersion>,
131}
132
133impl GraphVersionRange {
134    /// Creates an open-ended range beginning at a graph version.
135    pub const fn open_from(valid_from: GraphVersion) -> Self {
136        Self {
137            valid_from,
138            valid_until: None,
139        }
140    }
141
142    /// Validates that a bounded range is ordered.
143    pub fn new(
144        valid_from: GraphVersion,
145        valid_until: Option<GraphVersion>,
146    ) -> Result<Self, DomainError> {
147        if valid_until.is_some_and(|until| until < valid_from) {
148            return Err(DomainError::invalid(
149                "version_range",
150                "valid_until must not be before valid_from",
151            ));
152        }
153
154        Ok(Self {
155            valid_from,
156            valid_until,
157        })
158    }
159}
160
161/// Evidence accepted by the graph mutation pipeline.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct EvidenceRecord {
164    pub id: String,
165    pub source_scope: SourceScope,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub source_path: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub span: Option<EvidenceSpan>,
170    pub content: String,
171    pub entity_labels: Vec<String>,
172    pub confidence: ConfidenceScore,
173    pub status: FactStatus,
174    pub extraction: EvidenceExtractionMetadata,
175}
176
177impl EvidenceRecord {
178    /// Validates evidence and normalizes entity labels before persistence.
179    pub fn new(
180        id: impl Into<String>,
181        source_scope: SourceScope,
182        content: impl Into<String>,
183        entity_labels: Vec<String>,
184    ) -> Result<Self, DomainError> {
185        let normalized_labels = normalize_entity_labels(entity_labels)?;
186
187        Ok(Self {
188            id: required_text("evidence_id", id)?,
189            source_scope,
190            source_path: None,
191            span: None,
192            content: required_text("evidence_content", content)?,
193            entity_labels: normalized_labels,
194            confidence: ConfidenceScore::CERTAIN,
195            status: FactStatus::Accepted,
196            extraction: EvidenceExtractionMetadata::text_span(),
197        })
198    }
199
200    /// Attaches source-location and confidence metadata to evidence.
201    pub fn with_metadata(
202        mut self,
203        source_path: Option<String>,
204        span: Option<EvidenceSpan>,
205        confidence: ConfidenceScore,
206        status: FactStatus,
207    ) -> Result<Self, DomainError> {
208        self.source_path = source_path
209            .map(|path| required_text("source_path", path))
210            .transpose()?;
211        self.span = span.map(validate_span).transpose()?;
212        self.confidence = confidence.validate()?;
213        self.status = status;
214
215        Ok(self)
216    }
217
218    /// Attaches validated multimodal extraction metadata to the evidence.
219    pub fn with_extraction_metadata(
220        mut self,
221        extraction: EvidenceExtractionMetadata,
222    ) -> Result<Self, DomainError> {
223        self.extraction = extraction.validate()?;
224
225        Ok(self)
226    }
227}
228
229/// Typed relation between graph entities.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct GraphRelationRecord {
232    pub id: String,
233    pub source_scope: SourceScope,
234    pub source_entity_label: String,
235    pub relation_type: String,
236    pub target_entity_label: String,
237    pub evidence_ids: Vec<String>,
238    pub confidence: ConfidenceScore,
239    pub status: FactStatus,
240    pub version_range: GraphVersionRange,
241}
242
243/// Claim fact extracted from or supported by evidence.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ClaimRecord {
246    pub id: String,
247    pub source_scope: SourceScope,
248    pub subject_entity_label: String,
249    pub predicate: String,
250    pub object: String,
251    pub evidence_ids: Vec<String>,
252    pub confidence: ConfidenceScore,
253    pub status: FactStatus,
254    pub version_range: GraphVersionRange,
255}
256
257/// Event fact tied to entities and optional valid-time text.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct EventRecord {
260    pub id: String,
261    pub source_scope: SourceScope,
262    pub event_type: String,
263    pub entity_labels: Vec<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub occurred_at: Option<String>,
266    pub evidence_ids: Vec<String>,
267    pub confidence: ConfidenceScore,
268    pub status: FactStatus,
269    pub version_range: GraphVersionRange,
270}
271
272impl GraphRelationRecord {
273    /// Validates a typed relation and its supporting evidence references.
274    pub fn new(
275        id: impl Into<String>,
276        source_scope: SourceScope,
277        source_entity_label: impl Into<String>,
278        relation_type: impl Into<String>,
279        target_entity_label: impl Into<String>,
280        evidence_ids: Vec<String>,
281    ) -> Result<Self, DomainError> {
282        Ok(Self {
283            id: required_text("relation_id", id)?,
284            source_scope,
285            source_entity_label: required_text("source_entity_label", source_entity_label)?,
286            relation_type: required_text("relation_type", relation_type)?,
287            target_entity_label: required_text("target_entity_label", target_entity_label)?,
288            evidence_ids: normalize_evidence_ids(evidence_ids)?,
289            confidence: ConfidenceScore::CERTAIN,
290            status: FactStatus::Accepted,
291            version_range: GraphVersionRange::open_from(GraphVersion::ZERO),
292        })
293    }
294
295    /// Applies quality and lifecycle metadata after structural validation.
296    pub fn with_metadata(
297        mut self,
298        confidence: ConfidenceScore,
299        status: FactStatus,
300        version_range: GraphVersionRange,
301    ) -> Result<Self, DomainError> {
302        self.confidence = confidence.validate()?;
303        self.status = status;
304        self.version_range = validate_version_range(version_range)?;
305
306        Ok(self)
307    }
308}
309
310impl ClaimRecord {
311    /// Validates a claim and its supporting evidence references.
312    pub fn new(
313        id: impl Into<String>,
314        source_scope: SourceScope,
315        subject_entity_label: impl Into<String>,
316        predicate: impl Into<String>,
317        object: impl Into<String>,
318        evidence_ids: Vec<String>,
319    ) -> Result<Self, DomainError> {
320        Ok(Self {
321            id: required_text("claim_id", id)?,
322            source_scope,
323            subject_entity_label: required_text("subject_entity_label", subject_entity_label)?,
324            predicate: required_text("claim_predicate", predicate)?,
325            object: required_text("claim_object", object)?,
326            evidence_ids: normalize_evidence_ids(evidence_ids)?,
327            confidence: ConfidenceScore::CERTAIN,
328            status: FactStatus::Accepted,
329            version_range: GraphVersionRange::open_from(GraphVersion::ZERO),
330        })
331    }
332
333    /// Applies quality and lifecycle metadata after structural validation.
334    pub fn with_metadata(
335        mut self,
336        confidence: ConfidenceScore,
337        status: FactStatus,
338        version_range: GraphVersionRange,
339    ) -> Result<Self, DomainError> {
340        self.confidence = confidence.validate()?;
341        self.status = status;
342        self.version_range = validate_version_range(version_range)?;
343
344        Ok(self)
345    }
346}
347
348impl EventRecord {
349    /// Validates an event fact, linked entities, and supporting evidence.
350    pub fn new(
351        id: impl Into<String>,
352        source_scope: SourceScope,
353        event_type: impl Into<String>,
354        entity_labels: Vec<String>,
355        occurred_at: Option<String>,
356        evidence_ids: Vec<String>,
357    ) -> Result<Self, DomainError> {
358        let entity_labels = normalize_entity_labels(entity_labels)?;
359        if entity_labels.is_empty() {
360            return Err(DomainError::invalid(
361                "entity_label",
362                "event must reference at least one entity",
363            ));
364        }
365
366        Ok(Self {
367            id: required_text("event_id", id)?,
368            source_scope,
369            event_type: required_text("event_type", event_type)?,
370            entity_labels,
371            occurred_at: occurred_at
372                .map(|value| required_text("occurred_at", value))
373                .transpose()?,
374            evidence_ids: normalize_evidence_ids(evidence_ids)?,
375            confidence: ConfidenceScore::CERTAIN,
376            status: FactStatus::Accepted,
377            version_range: GraphVersionRange::open_from(GraphVersion::ZERO),
378        })
379    }
380
381    /// Applies quality and lifecycle metadata after structural validation.
382    pub fn with_metadata(
383        mut self,
384        confidence: ConfidenceScore,
385        status: FactStatus,
386        version_range: GraphVersionRange,
387    ) -> Result<Self, DomainError> {
388        self.confidence = confidence.validate()?;
389        self.status = status;
390        self.version_range = validate_version_range(version_range)?;
391
392        Ok(self)
393    }
394}
395
396/// A transactional graph mutation unit.
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct GraphMutationBatch {
399    pub evidence: Vec<EvidenceRecord>,
400    pub relations: Vec<GraphRelationRecord>,
401    pub claims: Vec<ClaimRecord>,
402    pub events: Vec<EventRecord>,
403}
404
405impl GraphMutationBatch {
406    /// Creates a non-empty batch for a single graph transaction.
407    pub fn new(evidence: Vec<EvidenceRecord>) -> Result<Self, DomainError> {
408        Self::with_facts(evidence, Vec::new(), Vec::new(), Vec::new())
409    }
410
411    /// Creates a non-empty batch containing evidence and structured facts.
412    pub fn with_facts(
413        evidence: Vec<EvidenceRecord>,
414        relations: Vec<GraphRelationRecord>,
415        claims: Vec<ClaimRecord>,
416        events: Vec<EventRecord>,
417    ) -> Result<Self, DomainError> {
418        if evidence.is_empty() && relations.is_empty() && claims.is_empty() && events.is_empty() {
419            return Err(DomainError::invalid(
420                "graph_facts",
421                "must include at least one graph fact",
422            ));
423        }
424        validate_unique_ids(
425            "evidence_id",
426            evidence.iter().map(|record| record.id.as_str()),
427        )?;
428        validate_unique_ids(
429            "relation_id",
430            relations.iter().map(|record| record.id.as_str()),
431        )?;
432        validate_unique_ids("claim_id", claims.iter().map(|record| record.id.as_str()))?;
433        validate_unique_ids("event_id", events.iter().map(|record| record.id.as_str()))?;
434        for record in &evidence {
435            if let Some(span) = record.span {
436                validate_span(span)?;
437            }
438            record.confidence.validate()?;
439        }
440        for record in &relations {
441            validate_evidence_ids_present(&record.evidence_ids)?;
442            record.confidence.validate()?;
443            validate_version_range(record.version_range)?;
444        }
445        for record in &claims {
446            validate_evidence_ids_present(&record.evidence_ids)?;
447            record.confidence.validate()?;
448            validate_version_range(record.version_range)?;
449        }
450        for record in &events {
451            validate_evidence_ids_present(&record.evidence_ids)?;
452            record.confidence.validate()?;
453            validate_version_range(record.version_range)?;
454        }
455
456        Ok(Self {
457            evidence,
458            relations,
459            claims,
460            events,
461        })
462    }
463}
464
465/// Receipt returned after graph facts and mutation log entries commit.
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467pub struct CommitReceipt {
468    pub graph_version: GraphVersion,
469    pub evidence_count: usize,
470    pub entity_count: usize,
471    pub relation_count: usize,
472    pub claim_count: usize,
473    pub event_count: usize,
474}
475
476fn normalize_entity_labels(labels: Vec<String>) -> Result<Vec<String>, DomainError> {
477    normalize_text_list("entity_label", labels)
478}
479
480fn normalize_evidence_ids(ids: Vec<String>) -> Result<Vec<String>, DomainError> {
481    let ids = normalize_text_list("evidence_id", ids)?;
482    validate_evidence_ids_present(&ids)?;
483
484    Ok(ids)
485}
486
487fn validate_evidence_ids_present(ids: &[String]) -> Result<(), DomainError> {
488    if ids.is_empty() {
489        return Err(DomainError::invalid(
490            "evidence_id",
491            "structured facts must reference supporting evidence",
492        ));
493    }
494
495    Ok(())
496}
497
498fn validate_span(span: EvidenceSpan) -> Result<EvidenceSpan, DomainError> {
499    EvidenceSpan::new(
500        span.start_byte,
501        span.end_byte,
502        span.start_line,
503        span.end_line,
504    )
505}
506
507fn validate_version_range(range: GraphVersionRange) -> Result<GraphVersionRange, DomainError> {
508    GraphVersionRange::new(range.valid_from, range.valid_until)
509}
510
511fn normalize_text_list(
512    field: &'static str,
513    values: Vec<String>,
514) -> Result<Vec<String>, DomainError> {
515    let mut normalized = Vec::new();
516    for value in values {
517        let value = required_text(field, value)?;
518        if !normalized.contains(&value) {
519            normalized.push(value);
520        }
521    }
522
523    Ok(normalized)
524}
525
526fn validate_unique_ids<'a>(
527    field: &'static str,
528    ids: impl IntoIterator<Item = &'a str>,
529) -> Result<(), DomainError> {
530    let mut seen = BTreeSet::new();
531    for id in ids {
532        if !seen.insert(id) {
533            return Err(DomainError::invalid(
534                field,
535                "must be unique within a mutation batch",
536            ));
537        }
538    }
539
540    Ok(())
541}
542
543#[cfg(test)]
544#[path = "mod_tests.rs"]
545mod tests;