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 { source_id, target_id, context } => {
90 write!(f, "dangling reference: {source_id} -> {target_id} ({context})")
91 }
92 Self::TemporalViolation { entity_id, event_id, description } => {
93 write!(f, "temporal violation: {entity_id} in {event_id}: {description}")
94 }
95 Self::StateViolation { entity_id, event_id, description } => {
96 write!(f, "state violation: {entity_id} in {event_id}: {description}")
97 }
98 }
99 }
100}
101
102impl fmt::Display for ValidationWarning {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::OrphanEntity { entity_id } => {
106 write!(f, "orphan entity: {entity_id} has no references in or out")
107 }
108 Self::TemporalAmbiguity { description } => {
109 write!(f, "temporal ambiguity: {description}")
110 }
111 }
112 }
113}
114
115pub fn validate(chronicle: &Chronicle) -> ValidationReport {
127 let mut report = ValidationReport::default();
128 validate_referential(chronicle, &mut report);
129 validate_temporal(chronicle, &mut report);
130 validate_state(chronicle, &chronicle.config, &mut report);
131 validate_orphans(chronicle, &mut report);
132 report
133}
134
135fn validate_referential(chronicle: &Chronicle, report: &mut ValidationReport) {
138 for nx in chronicle.graph.node_indices() {
139 let entity = &chronicle.graph[nx];
140 match entity {
141 Entity::Actor(actor) => {
142 for aff in &actor.affiliations {
143 check_ref(chronicle, &actor.id, aff, "affiliation", report);
144 }
145 if let Some(ev) = &actor.status_since_event {
146 check_ref(chronicle, &actor.id, ev, "status_since_event", report);
147 }
148 }
149 Entity::Place(place) => {
150 if let Some(r) = &place.region {
151 check_ref(chronicle, &place.id, r, "region", report);
152 }
153 if let Some(ev) = &place.status_since_event {
154 check_ref(chronicle, &place.id, ev, "status_since_event", report);
155 }
156 }
157 Entity::Event(event) => {
158 if let Some(loc) = &event.location {
159 check_ref(chronicle, &event.id, loc, "location", report);
160 }
161 for cause in &event.caused_by {
162 check_ref(chronicle, &event.id, cause, "caused_by", report);
163 }
164 for p in &event.participants {
165 check_ref(chronicle, &event.id, &p.actor, "participant", report);
166 }
167 for sc in &event.state_changes {
168 check_ref(chronicle, &event.id, &sc.entity, "state_change target", report);
169 }
170 }
171 Entity::Concept(concept) => {
172 if let Some(origin) = &concept.origin_event {
173 check_ref(chronicle, &concept.id, origin, "origin_event", report);
174 }
175 }
176 Entity::Account(account) => {
177 check_ref(chronicle, &account.id, &account.source, "source", report);
178 for ev in &account.event_refs {
179 check_ref(chronicle, &account.id, ev, "event_ref", report);
180 }
181 for mention in parse_references(&account.text) {
182 check_ref(chronicle, &account.id, &mention, "text mention", report);
183 }
184 }
185 }
186 }
187}
188
189fn check_ref(
190 chronicle: &Chronicle,
191 source_id: &str,
192 target_id: &str,
193 context: &str,
194 report: &mut ValidationReport,
195) {
196 if !chronicle.index.contains_key(target_id) {
197 report.errors.push(ValidationError::DanglingReference {
198 source_id: source_id.to_owned(),
199 target_id: target_id.to_owned(),
200 context: context.to_owned(),
201 });
202 }
203}
204
205fn to_interval(ts: &TimeSpan) -> Option<NonEmpty<Interval<i32>>> {
208 Interval { start: ts.start, end: ts.end + 1 }.try_into().ok()
210}
211
212fn strictly_before(a: &NonEmpty<Interval<i32>>, b: &NonEmpty<Interval<i32>>) -> bool {
214 a.precedes(b) || a.meets(b)
215}
216
217fn validate_temporal(chronicle: &Chronicle, report: &mut ValidationReport) {
218 for nx in chronicle.graph.node_indices() {
219 if let Entity::Event(event) = &chronicle.graph[nx] {
220 let Some(event_iv) = to_interval(&event.time_span) else { continue };
221
222 for p in &event.participants {
224 if let Some(&actor_nx) = chronicle.index.get(&p.actor)
225 && let Entity::Actor(actor) = &chronicle.graph[actor_nx]
226 && let Some(lifespan) = &actor.lifespan
227 && let Some(life_iv) = to_interval(lifespan)
228 {
229 if strictly_before(&event_iv, &life_iv) {
230 report.errors.push(ValidationError::TemporalViolation {
231 entity_id: actor.id.clone(),
232 event_id: event.id.clone(),
233 description: format!(
234 "event at {}-{} precedes actor lifespan {}-{}",
235 event.time_span.start, event.time_span.end,
236 lifespan.start, lifespan.end,
237 ),
238 });
239 }
240 if strictly_before(&life_iv, &event_iv) {
241 report.errors.push(ValidationError::TemporalViolation {
242 entity_id: actor.id.clone(),
243 event_id: event.id.clone(),
244 description: format!(
245 "actor lifespan {}-{} ends before event at {}-{}",
246 lifespan.start, lifespan.end,
247 event.time_span.start, event.time_span.end,
248 ),
249 });
250 }
251 }
252 }
253
254 for cause_id in &event.caused_by {
256 if let Some(&cause_nx) = chronicle.index.get(cause_id)
257 && let Entity::Event(cause_event) = &chronicle.graph[cause_nx]
258 && let Some(cause_iv) = to_interval(&cause_event.time_span)
259 && strictly_before(&event_iv, &cause_iv)
260 {
261 report.errors.push(ValidationError::TemporalViolation {
262 entity_id: event.id.clone(),
263 event_id: cause_event.id.clone(),
264 description: format!(
265 "effect {}-{} precedes its cause {}-{}",
266 event.time_span.start, event.time_span.end,
267 cause_event.time_span.start, cause_event.time_span.end,
268 ),
269 });
270 }
272 }
273 }
274 }
275}
276
277fn validate_state(chronicle: &Chronicle, config: &ValidationConfig, report: &mut ValidationReport) {
280 let state_changes = collect_state_changes(chronicle);
281
282 for nx in chronicle.graph.node_indices() {
283 if let Entity::Event(event) = &chronicle.graph[nx] {
284 let Some(event_iv) = to_interval(&event.time_span) else { continue };
285
286 for p in &event.participants {
288 for (entity_id, status, change_time, change_event_id) in &state_changes {
289 if *entity_id == p.actor
290 && config.terminal_statuses.contains(status)
291 && let Some(change_iv) = to_interval(change_time)
292 && strictly_before(&change_iv, &event_iv)
293 {
294 report.errors.push(ValidationError::StateViolation {
295 entity_id: p.actor.clone(),
296 event_id: event.id.clone(),
297 description: format!(
298 "actor '{}' has status {:?} as of '{}' (year {}-{}), \
299 cannot participate in '{}' (year {}-{})",
300 p.actor, status, change_event_id,
301 change_time.start, change_time.end,
302 event.id, event.time_span.start, event.time_span.end,
303 ),
304 });
305 }
306 }
307 }
308
309 if let Some(loc) = &event.location {
311 for (entity_id, status, change_time, change_event_id) in &state_changes {
312 if entity_id == loc.as_str()
313 && config.terminal_statuses.contains(status)
314 && let Some(change_iv) = to_interval(change_time)
315 && strictly_before(&change_iv, &event_iv)
316 {
317 report.errors.push(ValidationError::StateViolation {
318 entity_id: loc.clone(),
319 event_id: event.id.clone(),
320 description: format!(
321 "location '{}' has status {:?} as of '{}' (year {}-{}), \
322 cannot host '{}' (year {}-{})",
323 loc, status, change_event_id,
324 change_time.start, change_time.end,
325 event.id, event.time_span.start, event.time_span.end,
326 ),
327 });
328 }
329 }
330 }
331 }
332 }
333}
334
335fn validate_orphans(chronicle: &Chronicle, report: &mut ValidationReport) {
338 for nx in chronicle.graph.node_indices() {
339 let in_deg = chronicle.graph.neighbors_directed(nx, Direction::Incoming).count();
340 let out_deg = chronicle.graph.neighbors_directed(nx, Direction::Outgoing).count();
341 if in_deg == 0 && out_deg == 0 {
342 report.warnings.push(ValidationWarning::OrphanEntity {
343 entity_id: chronicle.graph[nx].id().to_owned(),
344 });
345 }
346 }
347}
348
349fn collect_state_changes(chronicle: &Chronicle) -> Vec<(String, Status, TimeSpan, String)> {
353 let mut changes = Vec::new();
354 for nx in chronicle.graph.node_indices() {
355 if let Entity::Event(event) = &chronicle.graph[nx] {
356 for sc in &event.state_changes {
357 if let StateChange::StatusChange(status) = &sc.change {
358 changes.push((
359 sc.entity.clone(),
360 status.clone(),
361 event.time_span.clone(),
362 event.id.clone(),
363 ));
364 }
365 }
366 }
367 }
368 changes
369}
370
371pub fn can_add_event(chronicle: &Chronicle, event: &Event) -> Result<(), Vec<ValidationError>> {
381 let mut errors = Vec::new();
382
383 if chronicle.index.contains_key(&event.id) {
385 errors.push(ValidationError::DanglingReference {
386 source_id: event.id.clone(),
387 target_id: event.id.clone(),
388 context: format!("proposed event '{}' has the same ID as an existing entity", event.id),
389 });
390 }
391
392 if let Some(loc) = &event.location
394 && !chronicle.index.contains_key(loc.as_str())
395 {
396 errors.push(ValidationError::DanglingReference {
397 source_id: event.id.clone(),
398 target_id: loc.clone(),
399 context: "location".to_owned(),
400 });
401 }
402 for cause_id in &event.caused_by {
403 if !chronicle.index.contains_key(cause_id.as_str()) {
404 errors.push(ValidationError::DanglingReference {
405 source_id: event.id.clone(),
406 target_id: cause_id.clone(),
407 context: "caused_by".to_owned(),
408 });
409 }
410 }
411 for p in &event.participants {
412 if !chronicle.index.contains_key(p.actor.as_str()) {
413 errors.push(ValidationError::DanglingReference {
414 source_id: event.id.clone(),
415 target_id: p.actor.clone(),
416 context: "participant".to_owned(),
417 });
418 }
419 }
420 for sc in &event.state_changes {
421 if !chronicle.index.contains_key(sc.entity.as_str()) {
422 errors.push(ValidationError::DanglingReference {
423 source_id: event.id.clone(),
424 target_id: sc.entity.clone(),
425 context: "state_change target".to_owned(),
426 });
427 }
428 }
429
430 if let Some(event_iv) = to_interval(&event.time_span) {
432 for p in &event.participants {
433 if let Some(&actor_nx) = chronicle.index.get(&p.actor)
434 && let Entity::Actor(actor) = &chronicle.graph[actor_nx]
435 && let Some(lifespan) = &actor.lifespan
436 && let Some(life_iv) = to_interval(lifespan)
437 {
438 if strictly_before(&event_iv, &life_iv) {
439 errors.push(ValidationError::TemporalViolation {
440 entity_id: actor.id.clone(),
441 event_id: event.id.clone(),
442 description: format!(
443 "proposed event '{}' (year {}-{}) precedes actor '{}' lifespan ({}-{})",
444 event.id, event.time_span.start, event.time_span.end,
445 actor.id, lifespan.start, lifespan.end,
446 ),
447 });
448 }
449 if strictly_before(&life_iv, &event_iv) {
450 errors.push(ValidationError::TemporalViolation {
451 entity_id: actor.id.clone(),
452 event_id: event.id.clone(),
453 description: format!(
454 "actor '{}' lifespan ({}-{}) ends before proposed event '{}' (year {}-{})",
455 actor.id, lifespan.start, lifespan.end,
456 event.id, event.time_span.start, event.time_span.end,
457 ),
458 });
459 }
460 }
461 }
462
463 for cause_id in &event.caused_by {
465 if let Some(&cause_nx) = chronicle.index.get(cause_id.as_str())
466 && let Entity::Event(cause_event) = &chronicle.graph[cause_nx]
467 && let Some(cause_iv) = to_interval(&cause_event.time_span)
468 && strictly_before(&event_iv, &cause_iv)
469 {
470 errors.push(ValidationError::TemporalViolation {
471 entity_id: event.id.clone(),
472 event_id: cause_event.id.clone(),
473 description: format!(
474 "proposed event '{}' (year {}-{}) precedes its cause '{}' (year {}-{})",
475 event.id, event.time_span.start, event.time_span.end,
476 cause_event.id, cause_event.time_span.start, cause_event.time_span.end,
477 ),
478 });
479 }
480 }
481
482 let state_changes = collect_state_changes(chronicle);
484
485 for p in &event.participants {
486 for (entity_id, status, change_time, change_event_id) in &state_changes {
487 if *entity_id == p.actor
488 && chronicle.config.terminal_statuses.contains(status)
489 && let Some(change_iv) = to_interval(change_time)
490 && strictly_before(&change_iv, &event_iv)
491 {
492 errors.push(ValidationError::StateViolation {
493 entity_id: p.actor.clone(),
494 event_id: event.id.clone(),
495 description: format!(
496 "actor '{}' has status {:?} as of '{}' (year {}-{}), \
497 cannot participate in proposed event '{}' (year {}-{})",
498 p.actor, status, change_event_id,
499 change_time.start, change_time.end,
500 event.id, event.time_span.start, event.time_span.end,
501 ),
502 });
503 }
504 }
505 }
506
507 if let Some(loc) = &event.location {
508 for (entity_id, status, change_time, change_event_id) in &state_changes {
509 if entity_id == loc.as_str()
510 && chronicle.config.terminal_statuses.contains(status)
511 && let Some(change_iv) = to_interval(change_time)
512 && strictly_before(&change_iv, &event_iv)
513 {
514 errors.push(ValidationError::StateViolation {
515 entity_id: loc.clone(),
516 event_id: event.id.clone(),
517 description: format!(
518 "location '{}' has status {:?} as of '{}' (year {}-{}), \
519 cannot host proposed event '{}' (year {}-{})",
520 loc, status, change_event_id,
521 change_time.start, change_time.end,
522 event.id, event.time_span.start, event.time_span.end,
523 ),
524 });
525 }
526 }
527 }
528 }
529
530 if errors.is_empty() { Ok(()) } else { Err(errors) }
531}