Skip to main content

chronicle/
validation.rs

1//! Validation passes: referential integrity, temporal consistency, state tracking, orphan detection.
2
3use 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// ── Report types ───────────────────────────────────────────
12
13/// The result of running all validation passes over a [`Chronicle`].
14///
15/// Contains collected errors (hard violations that indicate inconsistent data)
16/// and warnings (soft issues like orphan entities that may be intentional).
17#[derive(Debug, Default)]
18pub struct ValidationReport {
19    /// Hard validation failures: dangling references, temporal impossibilities,
20    /// and state violations (e.g. a dead actor participating in a future event).
21    pub errors: Vec<ValidationError>,
22    /// Soft issues that do not necessarily indicate broken data, such as
23    /// entities with no incoming or outgoing relationships.
24    pub warnings: Vec<ValidationWarning>,
25}
26
27impl ValidationReport {
28    /// Returns `true` if no errors were found. Warnings alone do not cause this to return `false`.
29    pub fn is_ok(&self) -> bool {
30        self.errors.is_empty()
31    }
32}
33
34/// A hard validation error indicating inconsistent world data.
35#[derive(Debug)]
36pub enum ValidationError {
37    /// A reference from one entity to another that does not exist in the graph.
38    DanglingReference {
39        /// The ID of the entity that contains the broken reference.
40        source_id: String,
41        /// The ID that was referenced but not found in the graph.
42        target_id: String,
43        /// Human-readable label for the kind of reference (e.g. `"location"`, `"participant"`).
44        context: String,
45    },
46    /// A temporal impossibility detected via Allen's Interval Algebra — for example,
47    /// an event occurring before a participant's lifespan or after a cause.
48    TemporalViolation {
49        /// The ID of the entity whose timeline is violated (actor or event).
50        entity_id: String,
51        /// The ID of the event involved in the violation.
52        event_id: String,
53        /// Human-readable explanation of the temporal conflict.
54        description: String,
55    },
56    /// An entity is used in an event despite having a terminal status
57    /// (e.g. `Dead`, `Destroyed`) from a strictly earlier event.
58    StateViolation {
59        /// The ID of the entity in a terminal state.
60        entity_id: String,
61        /// The ID of the event that illegally references the terminal entity.
62        event_id: String,
63        /// Human-readable explanation including the terminal status and the events involved.
64        description: String,
65    },
66}
67
68/// A soft validation warning that does not indicate broken data but may
69/// signal authoring oversights.
70#[derive(Debug)]
71pub enum ValidationWarning {
72    /// An entity with zero incoming and zero outgoing edges — it is completely
73    /// disconnected from the rest of the graph.
74    OrphanEntity {
75        /// The ID of the disconnected entity.
76        entity_id: String,
77    },
78    /// A temporal relationship that is ambiguous (e.g. overlapping intervals
79    /// where strict ordering was expected) but not provably wrong.
80    TemporalAmbiguity {
81        /// Human-readable explanation of the ambiguous temporal relationship.
82        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
136// ── Validation entry point ─────────────────────────────────
137
138/// Run all four validation passes over the given [`Chronicle`] and return a [`ValidationReport`].
139///
140/// The passes, executed in order, are:
141/// 1. **Referential integrity** — every ID referenced by an entity must exist in the graph.
142/// 2. **Temporal consistency** — participant lifespans and causal ordering are checked
143///    using Allen's Interval Algebra.
144/// 3. **State tracking** — entities with terminal statuses (per [`ValidationConfig`]) cannot
145///    appear in events that occur strictly after the status-changing event.
146/// 4. **Orphan detection** — entities with no edges in either direction produce warnings.
147pub 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
156// ── Referential integrity ──────────────────────────────────
157
158fn 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
232// ── Temporal consistency ───────────────────────────────────
233
234fn to_interval(ts: &TimeSpan) -> Option<NonEmpty<Interval<i32>>> {
235    // TimeSpan uses inclusive bounds; Allen's discrete intervals use exclusive end.
236    Interval {
237        start: ts.start,
238        end: ts.end + 1,
239    }
240    .try_into()
241    .ok()
242}
243
244/// True if interval `a` ends strictly before `b` starts (precedes or meets in Allen's algebra).
245fn 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            // Check participants' lifespans
257            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            // Check causal ordering: cause must not be after effect
293            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                    // cause precedes/meets effect = valid; overlap/equal = ambiguous, not error
311                }
312            }
313        }
314    }
315}
316
317// ── State tracking ─────────────────────────────────────────
318
319fn 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            // Check participants aren't in a terminal state from a strictly earlier event
329            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            // Check locations aren't in a terminal state
357            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
387// ── Orphan detection ───────────────────────────────────────
388
389fn 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
407// ── Shared helpers ─────────────────────────────────────────
408
409/// Collect all status changes from events: (entity_id, status, time_span, event_id).
410fn 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
429// ── can_add_event ──────────────────────────────────────────
430
431/// Check whether a proposed [`Event`] is consistent with the existing graph.
432///
433/// Runs referential, temporal, and state checks against the current [`Chronicle`]
434/// without modifying it. This is the pre-flight check for inserting new narrative content.
435///
436/// Returns `Ok(())` if the event can be safely added, or `Err` containing every
437/// detected [`ValidationError`] (the list is exhaustive, not short-circuited).
438pub fn can_add_event(chronicle: &Chronicle, event: &Event) -> Result<(), Vec<ValidationError>> {
439    let mut errors = Vec::new();
440
441    // Duplicate ID check
442    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    // Referential: all referenced entities must exist
454    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    // Temporal: lifespan checks for participants
492    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        // Temporal: causal ordering
529        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        // State: check participants and location against terminal statuses
552        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}