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::{ValidationError, ValidationReport, can_add_event, validate};
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 {
79            graph,
80            index,
81            config,
82        };
83        chronicle.build_edges();
84        Ok(chronicle)
85    }
86
87    /// Run all validation passes and return a [`ValidationReport`].
88    ///
89    /// The report aggregates referential-integrity, temporal-consistency,
90    /// state-validity, and orphan-detection errors found in the graph.
91    pub fn validate(&self) -> ValidationReport {
92        validate(self)
93    }
94
95    /// Check whether a proposed [`Event`] is consistent with the existing graph.
96    ///
97    /// Returns `Ok(())` if the event can be inserted without violating any
98    /// temporal or state constraints. Returns `Err` with a list of
99    /// [`ValidationError`]s describing every conflict found.
100    pub fn can_add_event(&self, event: &Event) -> Result<(), Vec<ValidationError>> {
101        can_add_event(self, event)
102    }
103
104    /// Build all edges from the entity data already in the graph.
105    fn build_edges(&mut self) {
106        // Collect edge data first to avoid borrow conflicts.
107        let mut edges: Vec<(NodeIndex, String, Relationship)> = Vec::new();
108
109        for nx in self.graph.node_indices() {
110            let entity = &self.graph[nx];
111            match entity {
112                Entity::Actor(actor) => {
113                    for aff in &actor.affiliations {
114                        edges.push((nx, aff.clone(), Relationship::AffiliatedWith));
115                    }
116                }
117                Entity::Event(event) => {
118                    if let Some(loc) = &event.location {
119                        edges.push((nx, loc.clone(), Relationship::OccurredAt));
120                    }
121                    for cause in &event.caused_by {
122                        edges.push((nx, cause.clone(), Relationship::CausedBy));
123                    }
124                    for p in &event.participants {
125                        edges.push((
126                            nx,
127                            p.actor.clone(),
128                            Relationship::ParticipatedIn {
129                                role: p.role.clone(),
130                                sentiment: p.sentiment.clone(),
131                            },
132                        ));
133                    }
134                    for sc in &event.state_changes {
135                        edges.push((
136                            nx,
137                            sc.entity.clone(),
138                            Relationship::HasStateChange {
139                                change: sc.change.clone(),
140                            },
141                        ));
142                    }
143                }
144                Entity::Concept(concept) => {
145                    if let Some(origin) = &concept.origin_event {
146                        edges.push((nx, origin.clone(), Relationship::OriginatedFrom));
147                    }
148                }
149                Entity::Account(account) => {
150                    edges.push((nx, account.source.clone(), Relationship::AuthoredBy));
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 {
210        Entity::Actor(self)
211    }
212}
213impl IntoEntity for Place {
214    fn into_entity(self) -> Entity {
215        Entity::Place(self)
216    }
217}
218impl IntoEntity for Event {
219    fn into_entity(self) -> Entity {
220        Entity::Event(self)
221    }
222}
223impl IntoEntity for Concept {
224    fn into_entity(self) -> Entity {
225        Entity::Concept(self)
226    }
227}
228impl IntoEntity for Account {
229    fn into_entity(self) -> Entity {
230        Entity::Account(self)
231    }
232}
233
234trait HasId {
235    fn id(&self) -> &str;
236}
237
238impl HasId for Actor {
239    fn id(&self) -> &str {
240        &self.id
241    }
242}
243impl HasId for Place {
244    fn id(&self) -> &str {
245        &self.id
246    }
247}
248impl HasId for Event {
249    fn id(&self) -> &str {
250        &self.id
251    }
252}
253impl HasId for Concept {
254    fn id(&self) -> &str {
255        &self.id
256    }
257}
258impl HasId for Account {
259    fn id(&self) -> &str {
260        &self.id
261    }
262}
263
264fn load_dir<T>(
265    root: &Path,
266    subdir: &str,
267    graph: &mut StableGraph<Entity, Relationship>,
268    index: &mut HashMap<String, NodeIndex>,
269) -> Result<(), ChronicleError>
270where
271    T: serde::de::DeserializeOwned + IntoEntity + HasId,
272{
273    let dir = root.join(subdir);
274    if !dir.exists() {
275        return Ok(());
276    }
277    for entry in fs::read_dir(&dir)? {
278        let entry = entry?;
279        let path = entry.path();
280        if path.extension().is_some_and(|e| e == "ron") {
281            let contents = fs::read_to_string(&path)?;
282            let items: Vec<T> = ron::from_str(&contents)?;
283            for item in items {
284                let id = item.id().to_owned();
285                if index.contains_key(&id) {
286                    return Err(ChronicleError::DuplicateId(id));
287                }
288                let nx = graph.add_node(item.into_entity());
289                index.insert(id, nx);
290            }
291        }
292    }
293    Ok(())
294}