Skip to main content

ontocore_catalog/
graph.rs

1//! Graph export for visualization webviews.
2
3use crate::entity_api::SubclassEdge;
4use crate::OntologyCatalog;
5use ontocore_core::{
6    limits::{MAX_GRAPH_EDGES, MAX_GRAPH_NODES},
7    EntityKind, AXIOM_KIND_DOMAIN, AXIOM_KIND_EQUIVALENT_CLASS, AXIOM_KIND_RANGE,
8    AXIOM_KIND_SUB_CLASS_OF,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashSet, VecDeque};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15#[error("{0}")]
16pub struct GraphError(String);
17
18impl From<String> for GraphError {
19    fn from(value: String) -> Self {
20        Self(value)
21    }
22}
23
24pub type GraphResult<T> = std::result::Result<T, GraphError>;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum GraphKind {
29    Class,
30    Property,
31    Import,
32    Neighborhood,
33}
34
35impl GraphKind {
36    pub fn parse(s: &str) -> Option<Self> {
37        match s {
38            "class" => Some(Self::Class),
39            "property" => Some(Self::Property),
40            "import" => Some(Self::Import),
41            "neighborhood" => Some(Self::Neighborhood),
42            _ => None,
43        }
44    }
45
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            Self::Class => "class",
49            Self::Property => "property",
50            Self::Import => "import",
51            Self::Neighborhood => "neighborhood",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Default, Deserialize)]
57pub struct GraphFilters {
58    pub ontology_iri: Option<String>,
59    #[serde(default)]
60    pub hide_deprecated: bool,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct GraphRequest {
65    pub graph_kind: String,
66    pub root_iri: Option<String>,
67    #[serde(default = "default_depth")]
68    pub depth: u32,
69    #[serde(default)]
70    pub include_inferred: bool,
71    #[serde(default)]
72    pub filters: GraphFilters,
73}
74
75fn default_depth() -> u32 {
76    2
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct GraphNode {
81    pub id: String,
82    pub label: String,
83    pub kind: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct GraphEdge {
88    pub source: String,
89    pub target: String,
90    pub kind: String,
91    pub inferred: bool,
92}
93
94#[derive(Debug, Clone, Serialize)]
95pub struct GraphPayload {
96    pub nodes: Vec<GraphNode>,
97    pub edges: Vec<GraphEdge>,
98    pub truncated: bool,
99    pub graph_kind: String,
100}
101
102pub struct GraphBuilder<'a> {
103    catalog: &'a OntologyCatalog,
104    inferred_edges: Option<&'a [SubclassEdge]>,
105}
106
107impl<'a> GraphBuilder<'a> {
108    pub fn new(catalog: &'a OntologyCatalog) -> Self {
109        Self { catalog, inferred_edges: None }
110    }
111
112    pub fn with_inferred_edges(mut self, edges: &'a [SubclassEdge]) -> Self {
113        self.inferred_edges = Some(edges);
114        self
115    }
116
117    pub fn build(&self, request: &GraphRequest) -> GraphResult<GraphPayload> {
118        let kind = GraphKind::parse(&request.graph_kind)
119            .ok_or_else(|| GraphError(format!("unknown graph_kind: {}", request.graph_kind)))?;
120        let depth = request.depth.clamp(1, 5);
121
122        let mut payload = match kind {
123            GraphKind::Class => self.build_class_graph(request),
124            GraphKind::Property => self.build_property_graph(request),
125            GraphKind::Import => self.build_import_graph(request),
126            GraphKind::Neighborhood => {
127                let root = request.root_iri.as_deref().ok_or_else(|| {
128                    GraphError("neighborhood graph requires root_iri".to_string())
129                })?;
130                self.build_neighborhood_graph(request, root, depth)
131            }
132        }?;
133
134        payload.graph_kind = kind.as_str().to_string();
135        Ok(payload)
136    }
137
138    fn entity_allowed(&self, iri: &str, filters: &GraphFilters) -> bool {
139        let Some(entity) = self.catalog.find_entity(iri) else {
140            return !filters.hide_deprecated;
141        };
142        if filters.hide_deprecated && entity.deprecated {
143            return false;
144        }
145        if let Some(ref ont) = filters.ontology_iri {
146            if entity.ontology_id != *ont {
147                return false;
148            }
149        }
150        true
151    }
152
153    fn add_node(
154        nodes: &mut Vec<GraphNode>,
155        node_ids: &mut HashSet<String>,
156        truncated: &mut bool,
157        id: String,
158        label: String,
159        kind: String,
160    ) {
161        if node_ids.contains(&id) {
162            return;
163        }
164        if nodes.len() >= MAX_GRAPH_NODES {
165            *truncated = true;
166            return;
167        }
168        node_ids.insert(id.clone());
169        nodes.push(GraphNode { id, label, kind });
170    }
171
172    fn add_edge(
173        edges: &mut Vec<GraphEdge>,
174        truncated: &mut bool,
175        source: String,
176        target: String,
177        kind: String,
178        inferred: bool,
179    ) {
180        if edges.len() >= MAX_GRAPH_EDGES {
181            *truncated = true;
182            return;
183        }
184        edges.push(GraphEdge { source, target, kind, inferred });
185    }
186
187    fn label_for(&self, iri: &str) -> String {
188        self.catalog
189            .find_entity(iri)
190            .and_then(|e| e.labels.first().cloned())
191            .or_else(|| self.catalog.find_entity(iri).map(|e| e.short_name.clone()))
192            .unwrap_or_else(|| short_name(iri))
193    }
194
195    fn kind_for(&self, iri: &str) -> String {
196        self.catalog
197            .find_entity(iri)
198            .map(|e| e.kind.as_str().to_string())
199            .unwrap_or_else(|| "other".to_string())
200    }
201
202    fn build_class_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
203        let mut nodes = Vec::new();
204        let mut edges = Vec::new();
205        let mut node_ids = HashSet::new();
206        let mut truncated = false;
207
208        let hierarchy = self.catalog.class_hierarchy();
209        for edge in &hierarchy.edges {
210            if !self.entity_allowed(&edge.child, &request.filters)
211                || !self.entity_allowed(&edge.parent, &request.filters)
212            {
213                continue;
214            }
215            Self::add_node(
216                &mut nodes,
217                &mut node_ids,
218                &mut truncated,
219                edge.child.clone(),
220                self.label_for(&edge.child),
221                self.kind_for(&edge.child),
222            );
223            Self::add_node(
224                &mut nodes,
225                &mut node_ids,
226                &mut truncated,
227                edge.parent.clone(),
228                self.label_for(&edge.parent),
229                self.kind_for(&edge.parent),
230            );
231            Self::add_edge(
232                &mut edges,
233                &mut truncated,
234                edge.child.clone(),
235                edge.parent.clone(),
236                "sub_class_of".to_string(),
237                false,
238            );
239        }
240
241        if request.include_inferred {
242            if let Some(inferred) = self.inferred_edges {
243                for edge in inferred {
244                    if !self.entity_allowed(&edge.child, &request.filters)
245                        || !self.entity_allowed(&edge.parent, &request.filters)
246                    {
247                        continue;
248                    }
249                    let is_new = !hierarchy
250                        .edges
251                        .iter()
252                        .any(|e| e.child == edge.child && e.parent == edge.parent);
253                    if !is_new {
254                        continue;
255                    }
256                    Self::add_node(
257                        &mut nodes,
258                        &mut node_ids,
259                        &mut truncated,
260                        edge.child.clone(),
261                        self.label_for(&edge.child),
262                        self.kind_for(&edge.child),
263                    );
264                    Self::add_node(
265                        &mut nodes,
266                        &mut node_ids,
267                        &mut truncated,
268                        edge.parent.clone(),
269                        self.label_for(&edge.parent),
270                        self.kind_for(&edge.parent),
271                    );
272                    Self::add_edge(
273                        &mut edges,
274                        &mut truncated,
275                        edge.child.clone(),
276                        edge.parent.clone(),
277                        "sub_class_of".to_string(),
278                        true,
279                    );
280                }
281            }
282        }
283
284        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
285    }
286
287    fn build_property_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
288        let mut nodes = Vec::new();
289        let mut edges = Vec::new();
290        let mut node_ids = HashSet::new();
291        let mut truncated = false;
292
293        for entity in &self.catalog.data().entities {
294            if entity.kind != EntityKind::ObjectProperty && entity.kind != EntityKind::DataProperty
295            {
296                continue;
297            }
298            if !self.entity_allowed(&entity.iri, &request.filters) {
299                continue;
300            }
301            Self::add_node(
302                &mut nodes,
303                &mut node_ids,
304                &mut truncated,
305                entity.iri.clone(),
306                entity.labels.first().cloned().unwrap_or_else(|| entity.short_name.clone()),
307                entity.kind.as_str().to_string(),
308            );
309        }
310
311        for axiom in &self.catalog.data().axioms {
312            let edge_kind = match axiom.axiom_kind.as_str() {
313                AXIOM_KIND_DOMAIN => "domain",
314                AXIOM_KIND_RANGE => "range",
315                _ => continue,
316            };
317            let Some(prop) = self.catalog.find_entity(&axiom.subject) else {
318                continue;
319            };
320            if prop.kind != EntityKind::ObjectProperty && prop.kind != EntityKind::DataProperty {
321                continue;
322            }
323            if !self.entity_allowed(&axiom.subject, &request.filters) {
324                continue;
325            }
326            Self::add_node(
327                &mut nodes,
328                &mut node_ids,
329                &mut truncated,
330                axiom.object.clone(),
331                self.label_for(&axiom.object),
332                self.kind_for(&axiom.object),
333            );
334            Self::add_edge(
335                &mut edges,
336                &mut truncated,
337                axiom.subject.clone(),
338                axiom.object.clone(),
339                edge_kind.to_string(),
340                false,
341            );
342        }
343
344        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
345    }
346
347    fn build_import_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
348        let mut nodes = Vec::new();
349        let mut edges = Vec::new();
350        let mut node_ids = HashSet::new();
351        let mut truncated = false;
352
353        for doc in &self.catalog.data().documents {
354            let ont_iri = doc.base_iri.clone().unwrap_or_else(|| doc.id.clone());
355            if let Some(ref filter) = request.filters.ontology_iri {
356                if &ont_iri != filter && &doc.id != filter {
357                    continue;
358                }
359            }
360            Self::add_node(
361                &mut nodes,
362                &mut node_ids,
363                &mut truncated,
364                ont_iri.clone(),
365                short_name(&ont_iri),
366                "ontology".to_string(),
367            );
368            for import in &doc.imports {
369                Self::add_node(
370                    &mut nodes,
371                    &mut node_ids,
372                    &mut truncated,
373                    import.clone(),
374                    short_name(import),
375                    "ontology".to_string(),
376                );
377                Self::add_edge(
378                    &mut edges,
379                    &mut truncated,
380                    ont_iri.clone(),
381                    import.clone(),
382                    "imports".to_string(),
383                    false,
384                );
385            }
386        }
387
388        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
389    }
390
391    fn build_neighborhood_graph(
392        &self,
393        request: &GraphRequest,
394        root: &str,
395        depth: u32,
396    ) -> Result<GraphPayload, String> {
397        let mut nodes = Vec::new();
398        let mut edges = Vec::new();
399        let mut node_ids = HashSet::new();
400        let mut truncated = false;
401
402        let hierarchy = self.catalog.class_hierarchy();
403        let mut adjacency: Vec<(String, String, String, bool)> = Vec::new();
404
405        for edge in &hierarchy.edges {
406            adjacency.push((
407                edge.child.clone(),
408                edge.parent.clone(),
409                "sub_class_of".to_string(),
410                false,
411            ));
412            adjacency.push((
413                edge.parent.clone(),
414                edge.child.clone(),
415                "super_class_of".to_string(),
416                false,
417            ));
418        }
419
420        if request.include_inferred {
421            if let Some(inferred) = self.inferred_edges {
422                for edge in inferred {
423                    adjacency.push((
424                        edge.child.clone(),
425                        edge.parent.clone(),
426                        "sub_class_of".to_string(),
427                        true,
428                    ));
429                    adjacency.push((
430                        edge.parent.clone(),
431                        edge.child.clone(),
432                        "super_class_of".to_string(),
433                        true,
434                    ));
435                }
436            }
437        }
438
439        for axiom in &self.catalog.data().axioms {
440            if axiom.axiom_kind == AXIOM_KIND_EQUIVALENT_CLASS
441                && (axiom.object.starts_with("http://") || axiom.object.starts_with("https://"))
442            {
443                adjacency.push((
444                    axiom.subject.clone(),
445                    axiom.object.clone(),
446                    "equivalent_class".to_string(),
447                    false,
448                ));
449                adjacency.push((
450                    axiom.object.clone(),
451                    axiom.subject.clone(),
452                    "equivalent_class".to_string(),
453                    false,
454                ));
455            } else if axiom.axiom_kind == AXIOM_KIND_SUB_CLASS_OF
456                && !axiom.object.starts_with("http://")
457                && !axiom.object.starts_with("https://")
458            {
459                for filler in restriction_fillers_in_expr(&axiom.object, self.catalog) {
460                    adjacency.push((
461                        axiom.subject.clone(),
462                        filler,
463                        "some_values_from".to_string(),
464                        false,
465                    ));
466                }
467            }
468        }
469
470        let mut visited = HashSet::new();
471        let mut queue = VecDeque::new();
472        queue.push_back((root.to_string(), 0u32));
473        visited.insert(root.to_string());
474
475        Self::add_node(
476            &mut nodes,
477            &mut node_ids,
478            &mut truncated,
479            root.to_string(),
480            self.label_for(root),
481            self.kind_for(root),
482        );
483
484        while let Some((current, d)) = queue.pop_front() {
485            if d >= depth {
486                continue;
487            }
488            for (src, tgt, kind, inferred) in &adjacency {
489                if src != &current {
490                    continue;
491                }
492                if !self.entity_allowed(tgt, &request.filters) {
493                    continue;
494                }
495                Self::add_node(
496                    &mut nodes,
497                    &mut node_ids,
498                    &mut truncated,
499                    tgt.clone(),
500                    self.label_for(tgt),
501                    self.kind_for(tgt),
502                );
503                Self::add_edge(
504                    &mut edges,
505                    &mut truncated,
506                    src.clone(),
507                    tgt.clone(),
508                    kind.clone(),
509                    *inferred,
510                );
511                if visited.insert(tgt.clone()) {
512                    queue.push_back((tgt.clone(), d + 1));
513                }
514            }
515        }
516
517        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
518    }
519}
520
521fn short_name(iri: &str) -> String {
522    let hash = iri.rfind('#');
523    let slash = iri.rfind('/');
524    match (hash, slash) {
525        (Some(h), Some(s)) => iri[h.max(s) + 1..].to_string(),
526        (Some(h), None) => iri[h + 1..].to_string(),
527        (None, Some(s)) => iri[s + 1..].to_string(),
528        _ => iri.to_string(),
529    }
530}
531
532fn restriction_fillers_in_expr(expr: &str, catalog: &OntologyCatalog) -> Vec<String> {
533    catalog
534        .data()
535        .entities
536        .iter()
537        .filter(|e| e.kind == EntityKind::Class)
538        .filter(|e| expr.contains(&e.iri) || expr.contains(&format!(":{}", e.short_name)))
539        .map(|e| e.iri.clone())
540        .collect()
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::IndexBuilder;
547    use std::path::Path;
548
549    #[test]
550    fn class_graph_from_fixtures() {
551        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
552        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
553        let payload = GraphBuilder::new(&catalog)
554            .build(&GraphRequest {
555                graph_kind: "class".to_string(),
556                root_iri: None,
557                depth: 2,
558                include_inferred: false,
559                filters: GraphFilters::default(),
560            })
561            .expect("graph");
562        assert!(!payload.nodes.is_empty());
563        assert!(!payload.edges.is_empty());
564    }
565
566    #[test]
567    fn import_graph_from_fixtures() {
568        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
569        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
570        let payload = GraphBuilder::new(&catalog)
571            .build(&GraphRequest {
572                graph_kind: "import".to_string(),
573                root_iri: None,
574                depth: 2,
575                include_inferred: false,
576                filters: GraphFilters::default(),
577            })
578            .expect("graph");
579        assert!(!payload.nodes.is_empty());
580    }
581
582    #[test]
583    fn property_graph_includes_domain_range_from_axioms() {
584        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
585        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
586        let payload = GraphBuilder::new(&catalog)
587            .build(&GraphRequest {
588                graph_kind: "property".to_string(),
589                root_iri: None,
590                depth: 2,
591                include_inferred: false,
592                filters: GraphFilters::default(),
593            })
594            .expect("graph");
595        assert!(
596            payload.edges.iter().any(|e| e.kind == "domain"),
597            "expected domain edges from axioms"
598        );
599        assert!(
600            payload.edges.iter().any(|e| e.kind == "range"),
601            "expected range edges from axioms"
602        );
603    }
604
605    #[test]
606    fn neighborhood_graph_includes_restriction_fillers_from_axioms() {
607        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
608        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
609        let patient = "http://example.org/clinic#Patient";
610        let record = "http://example.org/clinic#MedicalRecord";
611        let payload = GraphBuilder::new(&catalog)
612            .build(&GraphRequest {
613                graph_kind: "neighborhood".to_string(),
614                root_iri: Some(patient.to_string()),
615                depth: 2,
616                include_inferred: false,
617                filters: GraphFilters::default(),
618            })
619            .expect("graph");
620        assert!(
621            payload.edges.iter().any(|e| e.source == patient && e.target == record),
622            "expected Patient -> MedicalRecord restriction edge"
623        );
624    }
625}