Skip to main content

chronicle/
graph.rs

1//! Graph builder: load RON files, resolve references, build StableGraph.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use petgraph::stable_graph::{NodeIndex, StableGraph};
8
9use crate::error::ChronicleError;
10use crate::model::*;
11use crate::validation::{ValidationReport, ValidationError, validate, can_add_event};
12
13/// The core chronicle graph.
14///
15/// Holds a heterogeneous [`StableGraph`] of [`Entity`] nodes connected by
16/// [`Relationship`] edges, plus a string-to-index lookup map and a
17/// [`ValidationConfig`] that governs policy decisions such as which statuses
18/// are considered terminal.
19///
20/// Construct via [`Chronicle::from_directory`] (default config) or
21/// [`Chronicle::from_directory_with_config`] (custom config). Both load every
22/// `.ron` file under the conventional subdirectory layout (`actors/`, `places/`,
23/// `events/`, `concepts/`, `accounts/`), insert nodes, and wire up edges in a
24/// single pass.
25pub struct Chronicle {
26    /// The underlying directed graph. Nodes are [`Entity`] variants; edges are
27    /// [`Relationship`] variants that encode the kind and, where applicable,
28    /// the payload (role, sentiment, state-change) of each connection.
29    pub graph: StableGraph<Entity, Relationship>,
30
31    /// Maps every entity's string id to its [`NodeIndex`] in the graph,
32    /// providing O(1) lookup by id.
33    pub index: HashMap<String, NodeIndex>,
34
35    /// Policy configuration used during validation — controls which
36    /// [`Status`] values are treated as terminal (e.g. `Dead`, `Destroyed`).
37    pub config: ValidationConfig,
38}
39
40impl Chronicle {
41    /// Load all RON files from a directory, build the graph with default config.
42    ///
43    /// Equivalent to calling [`from_directory_with_config`](Self::from_directory_with_config)
44    /// with [`ValidationConfig::default()`].
45    ///
46    /// # Errors
47    ///
48    /// Returns [`ChronicleError`] on I/O failures, RON parse errors, or
49    /// duplicate entity ids across files.
50    pub fn from_directory(path: &Path) -> Result<Self, ChronicleError> {
51        Self::from_directory_with_config(path, ValidationConfig::default())
52    }
53
54    /// Load all RON files from a directory with a custom [`ValidationConfig`].
55    ///
56    /// Walks the conventional subdirectories (`actors/`, `places/`, `events/`,
57    /// `concepts/`, `accounts/`), deserialises every `.ron` file into the
58    /// corresponding entity type, inserts nodes, and then builds all edges in
59    /// a separate pass via `build_edges`.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`ChronicleError`] on I/O failures, RON parse errors, or
64    /// duplicate entity ids.
65    pub fn from_directory_with_config(
66        path: &Path,
67        config: ValidationConfig,
68    ) -> Result<Self, ChronicleError> {
69        let mut graph = StableGraph::new();
70        let mut index = HashMap::new();
71
72        load_dir::<Actor>(path, "actors", &mut graph, &mut index)?;
73        load_dir::<Place>(path, "places", &mut graph, &mut index)?;
74        load_dir::<Event>(path, "events", &mut graph, &mut index)?;
75        load_dir::<Concept>(path, "concepts", &mut graph, &mut index)?;
76        load_dir::<Account>(path, "accounts", &mut graph, &mut index)?;
77
78        let mut chronicle = Self { graph, index, config };
79        chronicle.build_edges();
80        Ok(chronicle)
81    }
82
83    /// Run all validation passes and return a [`ValidationReport`].
84    ///
85    /// The report aggregates referential-integrity, temporal-consistency,
86    /// state-validity, and orphan-detection errors found in the graph.
87    pub fn validate(&self) -> ValidationReport {
88        validate(self)
89    }
90
91    /// Check whether a proposed [`Event`] is consistent with the existing graph.
92    ///
93    /// Returns `Ok(())` if the event can be inserted without violating any
94    /// temporal or state constraints. Returns `Err` with a list of
95    /// [`ValidationError`]s describing every conflict found.
96    pub fn can_add_event(&self, event: &Event) -> Result<(), Vec<ValidationError>> {
97        can_add_event(self, event)
98    }
99
100    /// Build all edges from the entity data already in the graph.
101    fn build_edges(&mut self) {
102        // Collect edge data first to avoid borrow conflicts.
103        let mut edges: Vec<(NodeIndex, String, Relationship)> = Vec::new();
104
105        for nx in self.graph.node_indices() {
106            let entity = &self.graph[nx];
107            match entity {
108                Entity::Actor(actor) => {
109                    for aff in &actor.affiliations {
110                        edges.push((nx, aff.clone(), Relationship::AffiliatedWith));
111                    }
112                }
113                Entity::Event(event) => {
114                    if let Some(loc) = &event.location {
115                        edges.push((nx, loc.clone(), Relationship::OccurredAt));
116                    }
117                    for cause in &event.caused_by {
118                        edges.push((nx, cause.clone(), Relationship::CausedBy));
119                    }
120                    for p in &event.participants {
121                        edges.push((
122                            nx,
123                            p.actor.clone(),
124                            Relationship::ParticipatedIn {
125                                role: p.role.clone(),
126                                sentiment: p.sentiment.clone(),
127                            },
128                        ));
129                    }
130                    for sc in &event.state_changes {
131                        edges.push((
132                            nx,
133                            sc.entity.clone(),
134                            Relationship::HasStateChange {
135                                change: sc.change.clone(),
136                            },
137                        ));
138                    }
139                }
140                Entity::Concept(concept) => {
141                    if let Some(origin) = &concept.origin_event {
142                        edges.push((nx, origin.clone(), Relationship::OriginatedFrom));
143                    }
144                }
145                Entity::Account(account) => {
146                    edges.push((
147                        nx,
148                        account.source.clone(),
149                        Relationship::AuthoredBy,
150                    ));
151                    for ev in &account.event_refs {
152                        edges.push((nx, ev.clone(), Relationship::AccountOf));
153                    }
154                    for mention in parse_references(&account.text) {
155                        edges.push((nx, mention, Relationship::Mentions));
156                    }
157                }
158                Entity::Place(place) => {
159                    if let Some(r) = &place.region {
160                        edges.push((nx, r.clone(), Relationship::LocatedIn));
161                    }
162                }
163            }
164        }
165
166        for (from, target_id, rel) in edges {
167            if let Some(&to) = self.index.get(&target_id) {
168                self.graph.add_edge(from, to, rel);
169            }
170            // Dangling references are caught by validation, not here.
171        }
172    }
173}
174
175/// Extract `{entity_id}` references from account text.
176///
177/// Scans `text` for brace-delimited identifiers (e.g. `{silica}`) and returns
178/// the collected ids as a `Vec<String>`. Empty braces (`{}`) are ignored.
179///
180/// # Examples
181///
182/// ```
183/// use chronicle::graph::parse_references;
184///
185/// let refs = parse_references("We burned {silica}. The {mirror_order} call it a massacre.");
186/// assert_eq!(refs, vec!["silica".to_string(), "mirror_order".to_string()]);
187/// ```
188pub fn parse_references(text: &str) -> Vec<String> {
189    let mut refs = Vec::new();
190    let mut chars = text.chars().peekable();
191    while let Some(ch) = chars.next() {
192        if ch == '{' {
193            let id: String = chars.by_ref().take_while(|&c| c != '}').collect();
194            if !id.is_empty() {
195                refs.push(id);
196            }
197        }
198    }
199    refs
200}
201
202// ── Loading helpers ────────────────────────────────────────
203
204trait IntoEntity {
205    fn into_entity(self) -> Entity;
206}
207
208impl IntoEntity for Actor {
209    fn into_entity(self) -> Entity { Entity::Actor(self) }
210}
211impl IntoEntity for Place {
212    fn into_entity(self) -> Entity { Entity::Place(self) }
213}
214impl IntoEntity for Event {
215    fn into_entity(self) -> Entity { Entity::Event(self) }
216}
217impl IntoEntity for Concept {
218    fn into_entity(self) -> Entity { Entity::Concept(self) }
219}
220impl IntoEntity for Account {
221    fn into_entity(self) -> Entity { Entity::Account(self) }
222}
223
224trait HasId {
225    fn id(&self) -> &str;
226}
227
228impl HasId for Actor { fn id(&self) -> &str { &self.id } }
229impl HasId for Place { fn id(&self) -> &str { &self.id } }
230impl HasId for Event { fn id(&self) -> &str { &self.id } }
231impl HasId for Concept { fn id(&self) -> &str { &self.id } }
232impl HasId for Account { fn id(&self) -> &str { &self.id } }
233
234fn load_dir<T>(
235    root: &Path,
236    subdir: &str,
237    graph: &mut StableGraph<Entity, Relationship>,
238    index: &mut HashMap<String, NodeIndex>,
239) -> Result<(), ChronicleError>
240where
241    T: serde::de::DeserializeOwned + IntoEntity + HasId,
242{
243    let dir = root.join(subdir);
244    if !dir.exists() {
245        return Ok(());
246    }
247    for entry in fs::read_dir(&dir)? {
248        let entry = entry?;
249        let path = entry.path();
250        if path.extension().is_some_and(|e| e == "ron") {
251            let contents = fs::read_to_string(&path)?;
252            let items: Vec<T> = ron::from_str(&contents)?;
253            for item in items {
254                let id = item.id().to_owned();
255                if index.contains_key(&id) {
256                    return Err(ChronicleError::DuplicateId(id));
257                }
258                let nx = graph.add_node(item.into_entity());
259                index.insert(id, nx);
260            }
261        }
262    }
263    Ok(())
264}