1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6 DomainError, EvidenceExtractionMetadata, GraphVersion, SourceScope, error::required_text,
7};
8
9#[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 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 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#[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 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 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#[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 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#[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 pub const fn open_from(valid_from: GraphVersion) -> Self {
136 Self {
137 valid_from,
138 valid_until: None,
139 }
140 }
141
142 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#[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 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 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 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#[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#[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#[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 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 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 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 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 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 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#[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 pub fn new(evidence: Vec<EvidenceRecord>) -> Result<Self, DomainError> {
408 Self::with_facts(evidence, Vec::new(), Vec::new(), Vec::new())
409 }
410
411 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#[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;