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 { 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
115// ── Validation entry point ─────────────────────────────────
116
117/// Run all four validation passes over the given [`Chronicle`] and return a [`ValidationReport`].
118///
119/// The passes, executed in order, are:
120/// 1. **Referential integrity** — every ID referenced by an entity must exist in the graph.
121/// 2. **Temporal consistency** — participant lifespans and causal ordering are checked
122///    using Allen's Interval Algebra.
123/// 3. **State tracking** — entities with terminal statuses (per [`ValidationConfig`]) cannot
124///    appear in events that occur strictly after the status-changing event.
125/// 4. **Orphan detection** — entities with no edges in either direction produce warnings.
126pub 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
135// ── Referential integrity ──────────────────────────────────
136
137fn 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
205// ── Temporal consistency ───────────────────────────────────
206
207fn to_interval(ts: &TimeSpan) -> Option<NonEmpty<Interval<i32>>> {
208    // TimeSpan uses inclusive bounds; Allen's discrete intervals use exclusive end.
209    Interval { start: ts.start, end: ts.end + 1 }.try_into().ok()
210}
211
212/// True if interval `a` ends strictly before `b` starts (precedes or meets in Allen's algebra).
213fn 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            // Check participants' lifespans
223            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            // Check causal ordering: cause must not be after effect
255            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                    // cause precedes/meets effect = valid; overlap/equal = ambiguous, not error
271                }
272            }
273        }
274    }
275}
276
277// ── State tracking ─────────────────────────────────────────
278
279fn 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            // Check participants aren't in a terminal state from a strictly earlier event
287            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            // Check locations aren't in a terminal state
310            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
335// ── Orphan detection ───────────────────────────────────────
336
337fn 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
349// ── Shared helpers ─────────────────────────────────────────
350
351/// Collect all status changes from events: (entity_id, status, time_span, event_id).
352fn 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
371// ── can_add_event ──────────────────────────────────────────
372
373/// Check whether a proposed [`Event`] is consistent with the existing graph.
374///
375/// Runs referential, temporal, and state checks against the current [`Chronicle`]
376/// without modifying it. This is the pre-flight check for inserting new narrative content.
377///
378/// Returns `Ok(())` if the event can be safely added, or `Err` containing every
379/// detected [`ValidationError`] (the list is exhaustive, not short-circuited).
380pub fn can_add_event(chronicle: &Chronicle, event: &Event) -> Result<(), Vec<ValidationError>> {
381    let mut errors = Vec::new();
382
383    // Duplicate ID check
384    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    // Referential: all referenced entities must exist
393    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    // Temporal: lifespan checks for participants
431    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        // Temporal: causal ordering
464        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        // State: check participants and location against terminal statuses
483        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}