1use std::fmt;
4
5use allen_intervals::{Interval, Meets, NonEmpty, Precedes};
6use petgraph::Direction;
7
8use crate::graph::{Chronicle, parse_references};
9use crate::model::*;
10
11#[derive(Debug, Default)]
18pub struct ValidationReport {
19 pub errors: Vec<ValidationError>,
22 pub warnings: Vec<ValidationWarning>,
25}
26
27impl ValidationReport {
28 pub fn is_ok(&self) -> bool {
30 self.errors.is_empty()
31 }
32}
33
34#[derive(Debug)]
36pub enum ValidationError {
37 DanglingReference {
39 source_id: String,
41 target_id: String,
43 context: String,
45 },
46 TemporalViolation {
49 entity_id: String,
51 event_id: String,
53 description: String,
55 },
56 StateViolation {
59 entity_id: String,
61 event_id: String,
63 description: String,
65 },
66}
67
68#[derive(Debug)]
71pub enum ValidationWarning {
72 OrphanEntity {
75 entity_id: String,
77 },
78 TemporalAmbiguity {
81 description: String,
83 },
84}
85
86impl fmt::Display for ValidationError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 Self::DanglingReference {
90 source_id,
91 target_id,
92 context,
93 } => {
94 write!(
95 f,
96 "dangling reference: {source_id} -> {target_id} ({context})"
97 )
98 }
99 Self::TemporalViolation {
100 entity_id,
101 event_id,
102 description,
103 } => {
104 write!(
105 f,
106 "temporal violation: {entity_id} in {event_id}: {description}"
107 )
108 }
109 Self::StateViolation {
110 entity_id,
111 event_id,
112 description,
113 } => {
114 write!(
115 f,
116 "state violation: {entity_id} in {event_id}: {description}"
117 )
118 }
119 }
120 }
121}
122
123impl fmt::Display for ValidationWarning {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 match self {
126 Self::OrphanEntity { entity_id } => {
127 write!(f, "orphan entity: {entity_id} has no references in or out")
128 }
129 Self::TemporalAmbiguity { description } => {
130 write!(f, "temporal ambiguity: {description}")
131 }
132 }
133 }
134}
135
136pub fn validate(chronicle: &Chronicle) -> ValidationReport {
148 let mut report = ValidationReport::default();
149 validate_referential(chronicle, &mut report);
150 validate_temporal(chronicle, &mut report);
151 validate_state(chronicle, &chronicle.config, &mut report);
152 validate_orphans(chronicle, &mut report);
153 report
154}
155
156fn validate_referential(chronicle: &Chronicle, report: &mut ValidationReport) {
159 for nx in chronicle.graph.node_indices() {
160 let entity = &chronicle.graph[nx];
161 match entity {
162 Entity::Actor(actor) => {
163 for aff in &actor.affiliations {
164 check_ref(chronicle, &actor.id, aff, "affiliation", report);
165 }
166 if let Some(ev) = &actor.status_since_event {
167 check_ref(chronicle, &actor.id, ev, "status_since_event", report);
168 }
169 }
170 Entity::Place(place) => {
171 if let Some(r) = &place.region {
172 check_ref(chronicle, &place.id, r, "region", report);
173 }
174 if let Some(ev) = &place.status_since_event {
175 check_ref(chronicle, &place.id, ev, "status_since_event", report);
176 }
177 }
178 Entity::Event(event) => {
179 if let Some(loc) = &event.location {
180 check_ref(chronicle, &event.id, loc, "location", report);
181 }
182 for cause in &event.caused_by {
183 check_ref(chronicle, &event.id, cause, "caused_by", report);
184 }
185 for p in &event.participants {
186 check_ref(chronicle, &event.id, &p.actor, "participant", report);
187 }
188 for sc in &event.state_changes {
189 check_ref(
190 chronicle,
191 &event.id,
192 &sc.entity,
193 "state_change target",
194 report,
195 );
196 }
197 }
198 Entity::Concept(concept) => {
199 if let Some(origin) = &concept.origin_event {
200 check_ref(chronicle, &concept.id, origin, "origin_event", report);
201 }
202 }
203 Entity::Account(account) => {
204 check_ref(chronicle, &account.id, &account.source, "source", report);
205 for ev in &account.event_refs {
206 check_ref(chronicle, &account.id, ev, "event_ref", report);
207 }
208 for mention in parse_references(&account.text) {
209 check_ref(chronicle, &account.id, &mention, "text mention", report);
210 }
211 }
212 }
213 }
214}
215
216fn check_ref(
217 chronicle: &Chronicle,
218 source_id: &str,
219 target_id: &str,
220 context: &str,
221 report: &mut ValidationReport,
222) {
223 if !chronicle.index.contains_key(target_id) {
224 report.errors.push(ValidationError::DanglingReference {
225 source_id: source_id.to_owned(),
226 target_id: target_id.to_owned(),
227 context: context.to_owned(),
228 });
229 }
230}
231
232fn to_interval(ts: &TimeSpan) -> Option<NonEmpty<Interval<i32>>> {
235 Interval {
237 start: ts.start,
238 end: ts.end + 1,
239 }
240 .try_into()
241 .ok()
242}
243
244fn strictly_before(a: &NonEmpty<Interval<i32>>, b: &NonEmpty<Interval<i32>>) -> bool {
246 a.precedes(b) || a.meets(b)
247}
248
249fn validate_temporal(chronicle: &Chronicle, report: &mut ValidationReport) {
250 for nx in chronicle.graph.node_indices() {
251 if let Entity::Event(event) = &chronicle.graph[nx] {
252 let Some(event_iv) = to_interval(&event.time_span) else {
253 continue;
254 };
255
256 for p in &event.participants {
258 if let Some(&actor_nx) = chronicle.index.get(&p.actor)
259 && let Entity::Actor(actor) = &chronicle.graph[actor_nx]
260 && let Some(lifespan) = &actor.lifespan
261 && let Some(life_iv) = to_interval(lifespan)
262 {
263 if strictly_before(&event_iv, &life_iv) {
264 report.errors.push(ValidationError::TemporalViolation {
265 entity_id: actor.id.clone(),
266 event_id: event.id.clone(),
267 description: format!(
268 "event at {}-{} precedes actor lifespan {}-{}",
269 event.time_span.start,
270 event.time_span.end,
271 lifespan.start,
272 lifespan.end,
273 ),
274 });
275 }
276 if strictly_before(&life_iv, &event_iv) {
277 report.errors.push(ValidationError::TemporalViolation {
278 entity_id: actor.id.clone(),
279 event_id: event.id.clone(),
280 description: format!(
281 "actor lifespan {}-{} ends before event at {}-{}",
282 lifespan.start,
283 lifespan.end,
284 event.time_span.start,
285 event.time_span.end,
286 ),
287 });
288 }
289 }
290 }
291
292 for cause_id in &event.caused_by {
294 if let Some(&cause_nx) = chronicle.index.get(cause_id)
295 && let Entity::Event(cause_event) = &chronicle.graph[cause_nx]
296 && let Some(cause_iv) = to_interval(&cause_event.time_span)
297 && strictly_before(&event_iv, &cause_iv)
298 {
299 report.errors.push(ValidationError::TemporalViolation {
300 entity_id: event.id.clone(),
301 event_id: cause_event.id.clone(),
302 description: format!(
303 "effect {}-{} precedes its cause {}-{}",
304 event.time_span.start,
305 event.time_span.end,
306 cause_event.time_span.start,
307 cause_event.time_span.end,
308 ),
309 });
310 }
312 }
313 }
314 }
315}
316
317fn validate_state(chronicle: &Chronicle, config: &ValidationConfig, report: &mut ValidationReport) {
320 let state_changes = collect_state_changes(chronicle);
321
322 for nx in chronicle.graph.node_indices() {
323 if let Entity::Event(event) = &chronicle.graph[nx] {
324 let Some(event_iv) = to_interval(&event.time_span) else {
325 continue;
326 };
327
328 for p in &event.participants {
330 for (entity_id, status, change_time, change_event_id) in &state_changes {
331 if *entity_id == p.actor
332 && config.terminal_statuses.contains(status)
333 && let Some(change_iv) = to_interval(change_time)
334 && strictly_before(&change_iv, &event_iv)
335 {
336 report.errors.push(ValidationError::StateViolation {
337 entity_id: p.actor.clone(),
338 event_id: event.id.clone(),
339 description: format!(
340 "actor '{}' has status {:?} as of '{}' (year {}-{}), \
341 cannot participate in '{}' (year {}-{})",
342 p.actor,
343 status,
344 change_event_id,
345 change_time.start,
346 change_time.end,
347 event.id,
348 event.time_span.start,
349 event.time_span.end,
350 ),
351 });
352 }
353 }
354 }
355
356 if let Some(loc) = &event.location {
358 for (entity_id, status, change_time, change_event_id) in &state_changes {
359 if entity_id == loc.as_str()
360 && config.terminal_statuses.contains(status)
361 && let Some(change_iv) = to_interval(change_time)
362 && strictly_before(&change_iv, &event_iv)
363 {
364 report.errors.push(ValidationError::StateViolation {
365 entity_id: loc.clone(),
366 event_id: event.id.clone(),
367 description: format!(
368 "location '{}' has status {:?} as of '{}' (year {}-{}), \
369 cannot host '{}' (year {}-{})",
370 loc,
371 status,
372 change_event_id,
373 change_time.start,
374 change_time.end,
375 event.id,
376 event.time_span.start,
377 event.time_span.end,
378 ),
379 });
380 }
381 }
382 }
383 }
384 }
385}
386
387fn validate_orphans(chronicle: &Chronicle, report: &mut ValidationReport) {
390 for nx in chronicle.graph.node_indices() {
391 let in_deg = chronicle
392 .graph
393 .neighbors_directed(nx, Direction::Incoming)
394 .count();
395 let out_deg = chronicle
396 .graph
397 .neighbors_directed(nx, Direction::Outgoing)
398 .count();
399 if in_deg == 0 && out_deg == 0 {
400 report.warnings.push(ValidationWarning::OrphanEntity {
401 entity_id: chronicle.graph[nx].id().to_owned(),
402 });
403 }
404 }
405}
406
407fn collect_state_changes(chronicle: &Chronicle) -> Vec<(String, Status, TimeSpan, String)> {
411 let mut changes = Vec::new();
412 for nx in chronicle.graph.node_indices() {
413 if let Entity::Event(event) = &chronicle.graph[nx] {
414 for sc in &event.state_changes {
415 if let StateChange::StatusChange(status) = &sc.change {
416 changes.push((
417 sc.entity.clone(),
418 status.clone(),
419 event.time_span.clone(),
420 event.id.clone(),
421 ));
422 }
423 }
424 }
425 }
426 changes
427}
428
429pub fn can_add_event(chronicle: &Chronicle, event: &Event) -> Result<(), Vec<ValidationError>> {
439 let mut errors = Vec::new();
440
441 if chronicle.index.contains_key(&event.id) {
443 errors.push(ValidationError::DanglingReference {
444 source_id: event.id.clone(),
445 target_id: event.id.clone(),
446 context: format!(
447 "proposed event '{}' has the same ID as an existing entity",
448 event.id
449 ),
450 });
451 }
452
453 if let Some(loc) = &event.location
455 && !chronicle.index.contains_key(loc.as_str())
456 {
457 errors.push(ValidationError::DanglingReference {
458 source_id: event.id.clone(),
459 target_id: loc.clone(),
460 context: "location".to_owned(),
461 });
462 }
463 for cause_id in &event.caused_by {
464 if !chronicle.index.contains_key(cause_id.as_str()) {
465 errors.push(ValidationError::DanglingReference {
466 source_id: event.id.clone(),
467 target_id: cause_id.clone(),
468 context: "caused_by".to_owned(),
469 });
470 }
471 }
472 for p in &event.participants {
473 if !chronicle.index.contains_key(p.actor.as_str()) {
474 errors.push(ValidationError::DanglingReference {
475 source_id: event.id.clone(),
476 target_id: p.actor.clone(),
477 context: "participant".to_owned(),
478 });
479 }
480 }
481 for sc in &event.state_changes {
482 if !chronicle.index.contains_key(sc.entity.as_str()) {
483 errors.push(ValidationError::DanglingReference {
484 source_id: event.id.clone(),
485 target_id: sc.entity.clone(),
486 context: "state_change target".to_owned(),
487 });
488 }
489 }
490
491 if let Some(event_iv) = to_interval(&event.time_span) {
493 for p in &event.participants {
494 if let Some(&actor_nx) = chronicle.index.get(&p.actor)
495 && let Entity::Actor(actor) = &chronicle.graph[actor_nx]
496 && let Some(lifespan) = &actor.lifespan
497 && let Some(life_iv) = to_interval(lifespan)
498 {
499 if strictly_before(&event_iv, &life_iv) {
500 errors.push(ValidationError::TemporalViolation {
501 entity_id: actor.id.clone(),
502 event_id: event.id.clone(),
503 description: format!(
504 "proposed event '{}' (year {}-{}) precedes actor '{}' lifespan ({}-{})",
505 event.id,
506 event.time_span.start,
507 event.time_span.end,
508 actor.id,
509 lifespan.start,
510 lifespan.end,
511 ),
512 });
513 }
514 if strictly_before(&life_iv, &event_iv) {
515 errors.push(ValidationError::TemporalViolation {
516 entity_id: actor.id.clone(),
517 event_id: event.id.clone(),
518 description: format!(
519 "actor '{}' lifespan ({}-{}) ends before proposed event '{}' (year {}-{})",
520 actor.id, lifespan.start, lifespan.end,
521 event.id, event.time_span.start, event.time_span.end,
522 ),
523 });
524 }
525 }
526 }
527
528 for cause_id in &event.caused_by {
530 if let Some(&cause_nx) = chronicle.index.get(cause_id.as_str())
531 && let Entity::Event(cause_event) = &chronicle.graph[cause_nx]
532 && let Some(cause_iv) = to_interval(&cause_event.time_span)
533 && strictly_before(&event_iv, &cause_iv)
534 {
535 errors.push(ValidationError::TemporalViolation {
536 entity_id: event.id.clone(),
537 event_id: cause_event.id.clone(),
538 description: format!(
539 "proposed event '{}' (year {}-{}) precedes its cause '{}' (year {}-{})",
540 event.id,
541 event.time_span.start,
542 event.time_span.end,
543 cause_event.id,
544 cause_event.time_span.start,
545 cause_event.time_span.end,
546 ),
547 });
548 }
549 }
550
551 let state_changes = collect_state_changes(chronicle);
553
554 for p in &event.participants {
555 for (entity_id, status, change_time, change_event_id) in &state_changes {
556 if *entity_id == p.actor
557 && chronicle.config.terminal_statuses.contains(status)
558 && let Some(change_iv) = to_interval(change_time)
559 && strictly_before(&change_iv, &event_iv)
560 {
561 errors.push(ValidationError::StateViolation {
562 entity_id: p.actor.clone(),
563 event_id: event.id.clone(),
564 description: format!(
565 "actor '{}' has status {:?} as of '{}' (year {}-{}), \
566 cannot participate in proposed event '{}' (year {}-{})",
567 p.actor,
568 status,
569 change_event_id,
570 change_time.start,
571 change_time.end,
572 event.id,
573 event.time_span.start,
574 event.time_span.end,
575 ),
576 });
577 }
578 }
579 }
580
581 if let Some(loc) = &event.location {
582 for (entity_id, status, change_time, change_event_id) in &state_changes {
583 if entity_id == loc.as_str()
584 && chronicle.config.terminal_statuses.contains(status)
585 && let Some(change_iv) = to_interval(change_time)
586 && strictly_before(&change_iv, &event_iv)
587 {
588 errors.push(ValidationError::StateViolation {
589 entity_id: loc.clone(),
590 event_id: event.id.clone(),
591 description: format!(
592 "location '{}' has status {:?} as of '{}' (year {}-{}), \
593 cannot host proposed event '{}' (year {}-{})",
594 loc,
595 status,
596 change_event_id,
597 change_time.start,
598 change_time.end,
599 event.id,
600 event.time_span.start,
601 event.time_span.end,
602 ),
603 });
604 }
605 }
606 }
607 }
608
609 if errors.is_empty() {
610 Ok(())
611 } else {
612 Err(errors)
613 }
614}