Skip to main content

strixonomy_catalog/
graph.rs

1//! Graph export for visualization webviews.
2
3use crate::entity_api::SubclassEdge;
4use crate::OntologyCatalog;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashSet, VecDeque};
7use strixonomy_core::{
8    limits::{MAX_GRAPH_EDGES, MAX_GRAPH_NODES},
9    Entity, EntityKind, AXIOM_KIND_CLASS_ASSERTION, AXIOM_KIND_DOMAIN, AXIOM_KIND_EQUIVALENT_CLASS,
10    AXIOM_KIND_OBJECT_PROPERTY_ASSERTION, AXIOM_KIND_RANGE, AXIOM_KIND_SUB_CLASS_OF,
11    AXIOM_KIND_SUB_DATA_PROPERTY_OF, AXIOM_KIND_SUB_OBJECT_PROPERTY_OF,
12};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16#[error("{0}")]
17pub struct GraphError(String);
18
19impl From<String> for GraphError {
20    fn from(value: String) -> Self {
21        Self(value)
22    }
23}
24
25pub type GraphResult<T> = std::result::Result<T, GraphError>;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum GraphKind {
30    Class,
31    Property,
32    ObjectProperty,
33    DataProperty,
34    Individual,
35    Import,
36    Dependency,
37    Neighborhood,
38    QueryResult,
39    RefactorPreview,
40}
41
42impl GraphKind {
43    pub fn parse(s: &str) -> Option<Self> {
44        match s {
45            "class" => Some(Self::Class),
46            "property" => Some(Self::Property),
47            "object_property" => Some(Self::ObjectProperty),
48            "data_property" => Some(Self::DataProperty),
49            "individual" => Some(Self::Individual),
50            "import" => Some(Self::Import),
51            "dependency" => Some(Self::Dependency),
52            "neighborhood" => Some(Self::Neighborhood),
53            "query_result" => Some(Self::QueryResult),
54            "refactor_preview" => Some(Self::RefactorPreview),
55            _ => None,
56        }
57    }
58
59    pub fn as_str(&self) -> &'static str {
60        match self {
61            Self::Class => "class",
62            Self::Property => "property",
63            Self::ObjectProperty => "object_property",
64            Self::DataProperty => "data_property",
65            Self::Individual => "individual",
66            Self::Import => "import",
67            Self::Dependency => "dependency",
68            Self::Neighborhood => "neighborhood",
69            Self::QueryResult => "query_result",
70            Self::RefactorPreview => "refactor_preview",
71        }
72    }
73}
74
75#[derive(Debug, Clone, Default, Deserialize, Serialize)]
76pub struct GraphFilters {
77    pub ontology_iri: Option<String>,
78    #[serde(default)]
79    pub hide_deprecated: bool,
80    #[serde(default)]
81    pub entity_kinds: Vec<String>,
82    #[serde(default)]
83    pub namespaces: Vec<String>,
84    #[serde(default)]
85    pub relationship_kinds: Vec<String>,
86    pub search_text: Option<String>,
87}
88
89#[derive(Debug, Clone, Deserialize)]
90pub struct GraphRequest {
91    pub graph_kind: String,
92    pub root_iri: Option<String>,
93    /// Multi-root set for `query_result` / `refactor_preview` (and optional neighborhood seeds).
94    #[serde(default)]
95    pub root_iris: Vec<String>,
96    #[serde(default = "default_depth")]
97    pub depth: u32,
98    #[serde(default)]
99    pub include_inferred: bool,
100    #[serde(default)]
101    pub filters: GraphFilters,
102}
103
104fn default_depth() -> u32 {
105    2
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct GraphNode {
110    pub id: String,
111    pub label: String,
112    pub kind: String,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub namespace: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub ontology_iri: Option<String>,
117    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
118    pub deprecated: bool,
119    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120    pub unsatisfiable: bool,
121    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122    pub equivalent: bool,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct GraphEdge {
127    pub source: String,
128    pub target: String,
129    pub kind: String,
130    pub inferred: bool,
131}
132
133#[derive(Debug, Clone, Serialize)]
134pub struct GraphPayload {
135    pub nodes: Vec<GraphNode>,
136    pub edges: Vec<GraphEdge>,
137    pub truncated: bool,
138    pub graph_kind: String,
139}
140
141pub struct GraphBuilder<'a> {
142    catalog: &'a OntologyCatalog,
143    inferred_edges: Option<&'a [SubclassEdge]>,
144}
145
146impl<'a> GraphBuilder<'a> {
147    pub fn new(catalog: &'a OntologyCatalog) -> Self {
148        Self { catalog, inferred_edges: None }
149    }
150
151    pub fn with_inferred_edges(mut self, edges: &'a [SubclassEdge]) -> Self {
152        self.inferred_edges = Some(edges);
153        self
154    }
155
156    pub fn build(&self, request: &GraphRequest) -> GraphResult<GraphPayload> {
157        let kind = GraphKind::parse(&request.graph_kind)
158            .ok_or_else(|| GraphError(format!("unknown graph_kind: {}", request.graph_kind)))?;
159        let depth = request.depth.clamp(1, 5);
160
161        let mut payload = match kind {
162            GraphKind::Class => self.build_class_graph(request),
163            GraphKind::Property => self.build_property_graph(request, None),
164            GraphKind::ObjectProperty => {
165                self.build_property_hierarchy_graph(request, EntityKind::ObjectProperty)
166            }
167            GraphKind::DataProperty => {
168                self.build_property_hierarchy_graph(request, EntityKind::DataProperty)
169            }
170            GraphKind::Individual => {
171                let roots = collect_roots(request);
172                if roots.is_empty() {
173                    return Err(GraphError(
174                        "individual graph requires root_iri or root_iris".to_string(),
175                    ));
176                }
177                self.build_individual_graph(request, &roots, depth)
178            }
179            GraphKind::Import => self.build_import_graph(request, false),
180            GraphKind::Dependency => self.build_import_graph(request, true),
181            GraphKind::Neighborhood => {
182                let root = request.root_iri.as_deref().ok_or_else(|| {
183                    GraphError("neighborhood graph requires root_iri".to_string())
184                })?;
185                self.build_neighborhood_graph(request, root, depth)
186            }
187            GraphKind::QueryResult | GraphKind::RefactorPreview => {
188                let roots = collect_roots(request);
189                if roots.is_empty() {
190                    return Err(GraphError(format!(
191                        "{} graph requires root_iri or root_iris",
192                        kind.as_str()
193                    )));
194                }
195                self.build_seeded_result_graph(request, &roots, depth, kind)
196            }
197        }?;
198
199        payload.graph_kind = kind.as_str().to_string();
200        Ok(payload)
201    }
202
203    fn entity_allowed(&self, iri: &str, filters: &GraphFilters) -> bool {
204        let Some(entity) = self.catalog.find_entity(iri) else {
205            // Unknown IRIs are not in any indexed ontology — exclude them when filtering
206            // by ontology_iri so external/imported parents cannot bypass the filter (#117).
207            if filters.ontology_iri.is_some() {
208                return false;
209            }
210            if !filters.entity_kinds.is_empty() || !filters.namespaces.is_empty() {
211                return false;
212            }
213            if let Some(ref q) = filters.search_text {
214                let q = q.trim().to_lowercase();
215                if !q.is_empty()
216                    && !iri.to_lowercase().contains(&q)
217                    && !short_name(iri).to_lowercase().contains(&q)
218                {
219                    return false;
220                }
221            }
222            return !filters.hide_deprecated;
223        };
224        if filters.hide_deprecated && entity.deprecated {
225            return false;
226        }
227        if let Some(ref ont) = filters.ontology_iri {
228            if entity.ontology_id != *ont {
229                return false;
230            }
231        }
232        if !filters.entity_kinds.is_empty() {
233            let kind = entity.kind.as_str();
234            if !filters.entity_kinds.iter().any(|k| k == kind) {
235                return false;
236            }
237        }
238        if !filters.namespaces.is_empty() {
239            let ns = namespace_of(iri);
240            if !filters.namespaces.iter().any(|n| n == &ns || iri.starts_with(n)) {
241                return false;
242            }
243        }
244        if let Some(ref q) = filters.search_text {
245            let q = q.trim().to_lowercase();
246            if !q.is_empty() {
247                let label_hit = entity.labels.iter().any(|l| l.to_lowercase().contains(&q));
248                let iri_hit = entity.iri.to_lowercase().contains(&q)
249                    || entity.short_name.to_lowercase().contains(&q);
250                if !label_hit && !iri_hit {
251                    return false;
252                }
253            }
254        }
255        true
256    }
257
258    fn relationship_allowed(&self, kind: &str, filters: &GraphFilters) -> bool {
259        if filters.relationship_kinds.is_empty() {
260            return true;
261        }
262        filters.relationship_kinds.iter().any(|k| k == kind)
263    }
264
265    fn add_node_for(
266        &self,
267        nodes: &mut Vec<GraphNode>,
268        node_ids: &mut HashSet<String>,
269        truncated: &mut bool,
270        iri: &str,
271    ) {
272        if node_ids.contains(iri) {
273            return;
274        }
275        if nodes.len() >= MAX_GRAPH_NODES {
276            *truncated = true;
277            return;
278        }
279        node_ids.insert(iri.to_string());
280        nodes.push(self.make_node(iri));
281    }
282
283    fn make_node(&self, iri: &str) -> GraphNode {
284        if let Some(entity) = self.catalog.find_entity(iri) {
285            return graph_node_from_entity(entity);
286        }
287        GraphNode {
288            id: iri.to_string(),
289            label: short_name(iri),
290            kind: "other".to_string(),
291            namespace: Some(namespace_of(iri)),
292            ontology_iri: None,
293            deprecated: false,
294            unsatisfiable: false,
295            equivalent: false,
296        }
297    }
298
299    fn add_edge(
300        &self,
301        edges: &mut Vec<GraphEdge>,
302        truncated: &mut bool,
303        filters: &GraphFilters,
304        edge: GraphEdge,
305    ) {
306        if !self.relationship_allowed(&edge.kind, filters) {
307            return;
308        }
309        if edges.len() >= MAX_GRAPH_EDGES {
310            *truncated = true;
311            return;
312        }
313        edges.push(edge);
314    }
315
316    fn build_class_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
317        let mut nodes = Vec::new();
318        let mut edges = Vec::new();
319        let mut node_ids = HashSet::new();
320        let mut truncated = false;
321
322        let hierarchy = self.catalog.class_hierarchy();
323        for edge in &hierarchy.edges {
324            if !self.entity_allowed(&edge.child, &request.filters)
325                || !self.entity_allowed(&edge.parent, &request.filters)
326            {
327                continue;
328            }
329            self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.child);
330            self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.parent);
331            self.add_edge(
332                &mut edges,
333                &mut truncated,
334                &request.filters,
335                GraphEdge {
336                    source: edge.child.clone(),
337                    target: edge.parent.clone(),
338                    kind: "sub_class_of".to_string(),
339                    inferred: false,
340                },
341            );
342        }
343
344        if request.include_inferred {
345            if let Some(inferred) = self.inferred_edges {
346                for edge in inferred {
347                    if !self.entity_allowed(&edge.child, &request.filters)
348                        || !self.entity_allowed(&edge.parent, &request.filters)
349                    {
350                        continue;
351                    }
352                    let is_new = !hierarchy
353                        .edges
354                        .iter()
355                        .any(|e| e.child == edge.child && e.parent == edge.parent);
356                    if !is_new {
357                        continue;
358                    }
359                    self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.child);
360                    self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.parent);
361                    self.add_edge(
362                        &mut edges,
363                        &mut truncated,
364                        &request.filters,
365                        GraphEdge {
366                            source: edge.child.clone(),
367                            target: edge.parent.clone(),
368                            kind: "sub_class_of".to_string(),
369                            inferred: true,
370                        },
371                    );
372                }
373            }
374        }
375
376        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
377    }
378
379    fn build_property_graph(
380        &self,
381        request: &GraphRequest,
382        kind_filter: Option<EntityKind>,
383    ) -> Result<GraphPayload, String> {
384        let mut nodes = Vec::new();
385        let mut edges = Vec::new();
386        let mut node_ids = HashSet::new();
387        let mut truncated = false;
388
389        for entity in &self.catalog.data().entities {
390            let matches_kind = match kind_filter {
391                Some(k) => entity.kind == k,
392                None => {
393                    entity.kind == EntityKind::ObjectProperty
394                        || entity.kind == EntityKind::DataProperty
395                }
396            };
397            if !matches_kind {
398                continue;
399            }
400            if !self.entity_allowed(&entity.iri, &request.filters) {
401                continue;
402            }
403            self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &entity.iri);
404        }
405
406        for axiom in &self.catalog.data().axioms {
407            let edge_kind = match axiom.axiom_kind.as_str() {
408                AXIOM_KIND_DOMAIN => "domain",
409                AXIOM_KIND_RANGE => "range",
410                _ => continue,
411            };
412            let Some(prop) = self.catalog.find_entity(&axiom.subject) else {
413                continue;
414            };
415            let matches_kind = match kind_filter {
416                Some(k) => prop.kind == k,
417                None => {
418                    prop.kind == EntityKind::ObjectProperty || prop.kind == EntityKind::DataProperty
419                }
420            };
421            if !matches_kind {
422                continue;
423            }
424            if !self.entity_allowed(&axiom.subject, &request.filters) {
425                continue;
426            }
427            self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &axiom.object);
428            self.add_edge(
429                &mut edges,
430                &mut truncated,
431                &request.filters,
432                GraphEdge {
433                    source: axiom.subject.clone(),
434                    target: axiom.object.clone(),
435                    kind: edge_kind.to_string(),
436                    inferred: false,
437                },
438            );
439        }
440
441        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
442    }
443
444    fn build_property_hierarchy_graph(
445        &self,
446        request: &GraphRequest,
447        prop_kind: EntityKind,
448    ) -> Result<GraphPayload, String> {
449        let mut payload = self.build_property_graph(request, Some(prop_kind))?;
450        let axiom_kind = match prop_kind {
451            EntityKind::ObjectProperty => AXIOM_KIND_SUB_OBJECT_PROPERTY_OF,
452            EntityKind::DataProperty => AXIOM_KIND_SUB_DATA_PROPERTY_OF,
453            _ => return Ok(payload),
454        };
455        let mut node_ids: HashSet<String> = payload.nodes.iter().map(|n| n.id.clone()).collect();
456        let mut truncated = payload.truncated;
457
458        for axiom in &self.catalog.data().axioms {
459            if axiom.axiom_kind != axiom_kind {
460                continue;
461            }
462            if !self.entity_allowed(&axiom.subject, &request.filters)
463                || !self.entity_allowed(&axiom.object, &request.filters)
464            {
465                continue;
466            }
467            self.add_node_for(&mut payload.nodes, &mut node_ids, &mut truncated, &axiom.subject);
468            self.add_node_for(&mut payload.nodes, &mut node_ids, &mut truncated, &axiom.object);
469            self.add_edge(
470                &mut payload.edges,
471                &mut truncated,
472                &request.filters,
473                GraphEdge {
474                    source: axiom.subject.clone(),
475                    target: axiom.object.clone(),
476                    kind: "sub_property_of".to_string(),
477                    inferred: false,
478                },
479            );
480        }
481
482        // Subclass-style inferred edges are class-only today; property inferred overlays
483        // fall back to asserted hierarchy (documented UI behavior).
484        payload.truncated = truncated;
485        Ok(payload)
486    }
487
488    fn build_import_graph(
489        &self,
490        request: &GraphRequest,
491        full_closure: bool,
492    ) -> Result<GraphPayload, String> {
493        let mut nodes = Vec::new();
494        let mut edges = Vec::new();
495        let mut node_ids = HashSet::new();
496        let mut truncated = false;
497
498        let mut adjacency: Vec<(String, String)> = Vec::new();
499        for doc in &self.catalog.data().documents {
500            let ont_iri = doc.base_iri.clone().unwrap_or_else(|| doc.id.clone());
501            for import in &doc.imports {
502                adjacency.push((ont_iri.clone(), import.clone()));
503            }
504        }
505
506        let seeds: Vec<String> = if let Some(ref filter) = request.filters.ontology_iri {
507            self.catalog
508                .data()
509                .documents
510                .iter()
511                .filter_map(|doc| {
512                    let ont_iri = doc.base_iri.clone().unwrap_or_else(|| doc.id.clone());
513                    if &ont_iri == filter || &doc.id == filter {
514                        Some(ont_iri)
515                    } else {
516                        None
517                    }
518                })
519                .collect()
520        } else {
521            self.catalog
522                .data()
523                .documents
524                .iter()
525                .map(|doc| doc.base_iri.clone().unwrap_or_else(|| doc.id.clone()))
526                .collect()
527        };
528
529        if full_closure {
530            let mut visited = HashSet::new();
531            let mut queue = VecDeque::new();
532            for seed in &seeds {
533                queue.push_back(seed.clone());
534                visited.insert(seed.clone());
535            }
536            while let Some(current) = queue.pop_front() {
537                self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &current);
538                for (src, tgt) in &adjacency {
539                    if src != &current {
540                        continue;
541                    }
542                    self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, tgt);
543                    self.add_edge(
544                        &mut edges,
545                        &mut truncated,
546                        &request.filters,
547                        GraphEdge {
548                            source: src.clone(),
549                            target: tgt.clone(),
550                            kind: "imports".to_string(),
551                            inferred: false,
552                        },
553                    );
554                    if visited.insert(tgt.clone()) {
555                        queue.push_back(tgt.clone());
556                    }
557                }
558            }
559        } else {
560            for seed in &seeds {
561                self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, seed);
562            }
563            for (src, tgt) in &adjacency {
564                if request.filters.ontology_iri.is_some() && !seeds.iter().any(|s| s == src) {
565                    continue;
566                }
567                self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, src);
568                self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, tgt);
569                self.add_edge(
570                    &mut edges,
571                    &mut truncated,
572                    &request.filters,
573                    GraphEdge {
574                        source: src.clone(),
575                        target: tgt.clone(),
576                        kind: "imports".to_string(),
577                        inferred: false,
578                    },
579                );
580            }
581        }
582
583        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
584    }
585
586    fn build_individual_graph(
587        &self,
588        request: &GraphRequest,
589        roots: &[String],
590        depth: u32,
591    ) -> Result<GraphPayload, String> {
592        let mut adjacency: Vec<(String, String, String, bool)> = Vec::new();
593        for axiom in &self.catalog.data().axioms {
594            match axiom.axiom_kind.as_str() {
595                AXIOM_KIND_CLASS_ASSERTION => {
596                    adjacency.push((
597                        axiom.subject.clone(),
598                        axiom.object.clone(),
599                        "type".to_string(),
600                        false,
601                    ));
602                    adjacency.push((
603                        axiom.object.clone(),
604                        axiom.subject.clone(),
605                        "instance".to_string(),
606                        false,
607                    ));
608                }
609                AXIOM_KIND_OBJECT_PROPERTY_ASSERTION => {
610                    adjacency.push((
611                        axiom.subject.clone(),
612                        axiom.object.clone(),
613                        "assertion".to_string(),
614                        false,
615                    ));
616                    adjacency.push((
617                        axiom.object.clone(),
618                        axiom.subject.clone(),
619                        "assertion".to_string(),
620                        false,
621                    ));
622                    // Also surface the property as a connector via predicate label on edges.
623                    let _ = &axiom.predicate;
624                }
625                _ => {}
626            }
627        }
628        self.bfs_from_roots(request, roots, depth, &adjacency, "individual")
629    }
630
631    fn build_neighborhood_graph(
632        &self,
633        request: &GraphRequest,
634        root: &str,
635        depth: u32,
636    ) -> Result<GraphPayload, String> {
637        let adjacency = self.class_neighborhood_adjacency(request.include_inferred);
638        self.bfs_from_roots(request, &[root.to_string()], depth, &adjacency, "neighborhood")
639    }
640
641    fn build_seeded_result_graph(
642        &self,
643        request: &GraphRequest,
644        roots: &[String],
645        depth: u32,
646        kind: GraphKind,
647    ) -> Result<GraphPayload, String> {
648        let adjacency = self.class_neighborhood_adjacency(request.include_inferred);
649        let edge_kind_prefix = match kind {
650            GraphKind::QueryResult => "query",
651            GraphKind::RefactorPreview => "refactor",
652            _ => "seed",
653        };
654        let mut payload =
655            self.bfs_from_roots(request, roots, depth, &adjacency, edge_kind_prefix)?;
656        // Ensure every seed appears even when isolated.
657        let mut node_ids: HashSet<String> = payload.nodes.iter().map(|n| n.id.clone()).collect();
658        let mut truncated = payload.truncated;
659        for root in roots {
660            self.add_node_for(&mut payload.nodes, &mut node_ids, &mut truncated, root);
661        }
662        // Link consecutive seeds so multi-root results form a visible connected set.
663        if roots.len() > 1 {
664            for window in roots.windows(2) {
665                self.add_edge(
666                    &mut payload.edges,
667                    &mut truncated,
668                    &request.filters,
669                    GraphEdge {
670                        source: window[0].clone(),
671                        target: window[1].clone(),
672                        kind: format!("{edge_kind_prefix}_result"),
673                        inferred: false,
674                    },
675                );
676            }
677        }
678        payload.truncated = truncated;
679        Ok(payload)
680    }
681
682    fn class_neighborhood_adjacency(
683        &self,
684        include_inferred: bool,
685    ) -> Vec<(String, String, String, bool)> {
686        let hierarchy = self.catalog.class_hierarchy();
687        let mut adjacency: Vec<(String, String, String, bool)> = Vec::new();
688
689        for edge in &hierarchy.edges {
690            adjacency.push((
691                edge.child.clone(),
692                edge.parent.clone(),
693                "sub_class_of".to_string(),
694                false,
695            ));
696            adjacency.push((
697                edge.parent.clone(),
698                edge.child.clone(),
699                "super_class_of".to_string(),
700                false,
701            ));
702        }
703
704        if include_inferred {
705            if let Some(inferred) = self.inferred_edges {
706                for edge in inferred {
707                    adjacency.push((
708                        edge.child.clone(),
709                        edge.parent.clone(),
710                        "sub_class_of".to_string(),
711                        true,
712                    ));
713                    adjacency.push((
714                        edge.parent.clone(),
715                        edge.child.clone(),
716                        "super_class_of".to_string(),
717                        true,
718                    ));
719                }
720            }
721        }
722
723        for axiom in &self.catalog.data().axioms {
724            if axiom.axiom_kind == AXIOM_KIND_EQUIVALENT_CLASS
725                && (axiom.object.starts_with("http://") || axiom.object.starts_with("https://"))
726            {
727                adjacency.push((
728                    axiom.subject.clone(),
729                    axiom.object.clone(),
730                    "equivalent_class".to_string(),
731                    false,
732                ));
733                adjacency.push((
734                    axiom.object.clone(),
735                    axiom.subject.clone(),
736                    "equivalent_class".to_string(),
737                    false,
738                ));
739            } else if axiom.axiom_kind == AXIOM_KIND_SUB_CLASS_OF
740                && !axiom.object.starts_with("http://")
741                && !axiom.object.starts_with("https://")
742            {
743                for filler in restriction_fillers_in_expr(&axiom.object, self.catalog) {
744                    adjacency.push((
745                        axiom.subject.clone(),
746                        filler,
747                        "some_values_from".to_string(),
748                        false,
749                    ));
750                }
751            }
752        }
753        adjacency
754    }
755
756    fn bfs_from_roots(
757        &self,
758        request: &GraphRequest,
759        roots: &[String],
760        depth: u32,
761        adjacency: &[(String, String, String, bool)],
762        _tag: &str,
763    ) -> Result<GraphPayload, String> {
764        let mut nodes = Vec::new();
765        let mut edges = Vec::new();
766        let mut node_ids = HashSet::new();
767        let mut truncated = false;
768        let mut visited = HashSet::new();
769        let mut queue = VecDeque::new();
770
771        for root in roots {
772            queue.push_back((root.clone(), 0u32));
773            visited.insert(root.clone());
774            self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, root);
775        }
776
777        while let Some((current, d)) = queue.pop_front() {
778            if d >= depth {
779                continue;
780            }
781            for (src, tgt, kind, inferred) in adjacency {
782                if src != &current {
783                    continue;
784                }
785                if !self.entity_allowed(tgt, &request.filters) {
786                    continue;
787                }
788                self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, tgt);
789                self.add_edge(
790                    &mut edges,
791                    &mut truncated,
792                    &request.filters,
793                    GraphEdge {
794                        source: src.clone(),
795                        target: tgt.clone(),
796                        kind: kind.clone(),
797                        inferred: *inferred,
798                    },
799                );
800                if visited.insert(tgt.clone()) {
801                    queue.push_back((tgt.clone(), d + 1));
802                }
803            }
804        }
805
806        Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
807    }
808}
809
810fn collect_roots(request: &GraphRequest) -> Vec<String> {
811    let mut roots = request.root_iris.clone();
812    if let Some(ref root) = request.root_iri {
813        if !roots.iter().any(|r| r == root) {
814            roots.insert(0, root.clone());
815        }
816    }
817    roots
818}
819
820fn graph_node_from_entity(entity: &Entity) -> GraphNode {
821    GraphNode {
822        id: entity.iri.clone(),
823        label: entity.labels.first().cloned().unwrap_or_else(|| entity.short_name.clone()),
824        kind: entity.kind.as_str().to_string(),
825        namespace: Some(namespace_of(&entity.iri)),
826        ontology_iri: Some(entity.ontology_id.clone()),
827        deprecated: entity.deprecated,
828        unsatisfiable: false,
829        equivalent: false,
830    }
831}
832
833fn namespace_of(iri: &str) -> String {
834    if let Some(h) = iri.rfind('#') {
835        return iri[..=h].to_string();
836    }
837    if let Some(s) = iri.rfind('/') {
838        return iri[..=s].to_string();
839    }
840    iri.to_string()
841}
842
843fn short_name(iri: &str) -> String {
844    let hash = iri.rfind('#');
845    let slash = iri.rfind('/');
846    match (hash, slash) {
847        (Some(h), Some(s)) => iri[h.max(s) + 1..].to_string(),
848        (Some(h), None) => iri[h + 1..].to_string(),
849        (None, Some(s)) => iri[s + 1..].to_string(),
850        _ => iri.to_string(),
851    }
852}
853
854fn restriction_fillers_in_expr(expr: &str, catalog: &OntologyCatalog) -> Vec<String> {
855    catalog
856        .data()
857        .entities
858        .iter()
859        .filter(|e| e.kind == EntityKind::Class)
860        .filter(|e| expr.contains(&e.iri) || expr.contains(&format!(":{}", e.short_name)))
861        .map(|e| e.iri.clone())
862        .collect()
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868    use crate::IndexBuilder;
869    use std::path::Path;
870
871    fn default_request(kind: &str) -> GraphRequest {
872        GraphRequest {
873            graph_kind: kind.to_string(),
874            root_iri: None,
875            root_iris: vec![],
876            depth: 2,
877            include_inferred: false,
878            filters: GraphFilters::default(),
879        }
880    }
881
882    #[test]
883    fn class_graph_from_fixtures() {
884        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
885        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
886        let payload = GraphBuilder::new(&catalog).build(&default_request("class")).expect("graph");
887        assert!(!payload.nodes.is_empty());
888        assert!(!payload.edges.is_empty());
889        assert!(payload.nodes.iter().any(|n| n.namespace.is_some()));
890    }
891
892    #[test]
893    fn import_graph_from_fixtures() {
894        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
895        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
896        let payload = GraphBuilder::new(&catalog).build(&default_request("import")).expect("graph");
897        assert!(!payload.nodes.is_empty());
898    }
899
900    #[test]
901    fn dependency_graph_from_fixtures() {
902        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
903        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
904        let payload =
905            GraphBuilder::new(&catalog).build(&default_request("dependency")).expect("graph");
906        assert!(!payload.nodes.is_empty());
907        assert_eq!(payload.graph_kind, "dependency");
908    }
909
910    #[test]
911    fn property_graph_includes_domain_range_from_axioms() {
912        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
913        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
914        let payload =
915            GraphBuilder::new(&catalog).build(&default_request("property")).expect("graph");
916        assert!(
917            payload.edges.iter().any(|e| e.kind == "domain"),
918            "expected domain edges from axioms"
919        );
920        assert!(
921            payload.edges.iter().any(|e| e.kind == "range"),
922            "expected range edges from axioms"
923        );
924    }
925
926    #[test]
927    fn object_property_hierarchy_includes_sub_property_edges() {
928        let dir = tempfile::tempdir().expect("tempdir");
929        std::fs::write(
930            dir.path().join("props.ttl"),
931            "@prefix ex: <http://ex#> .\n\
932             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
933             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
934             <http://ex/onto> a owl:Ontology .\n\
935             ex:hasRelative a owl:ObjectProperty .\n\
936             ex:hasParent a owl:ObjectProperty ; rdfs:subPropertyOf ex:hasRelative .\n",
937        )
938        .expect("write");
939        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
940        let payload =
941            GraphBuilder::new(&catalog).build(&default_request("object_property")).expect("graph");
942        assert!(
943            payload.edges.iter().any(|e| e.kind == "sub_property_of"),
944            "expected sub_property_of edges: {:?}",
945            payload.edges
946        );
947    }
948
949    #[test]
950    fn individual_graph_includes_type_and_assertions() {
951        let dir = tempfile::tempdir().expect("tempdir");
952        std::fs::write(
953            dir.path().join("inds.ttl"),
954            "@prefix ex: <http://ex#> .\n\
955             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
956             @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n\
957             <http://ex/onto> a owl:Ontology .\n\
958             ex:Person a owl:Class .\n\
959             ex:knows a owl:ObjectProperty .\n\
960             ex:Alice a owl:NamedIndividual , ex:Person .\n\
961             ex:Bob a owl:NamedIndividual .\n\
962             ex:Alice ex:knows ex:Bob .\n",
963        )
964        .expect("write");
965        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
966        let mut req = default_request("individual");
967        req.root_iri = Some("http://ex#Alice".to_string());
968        let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
969        assert!(payload.nodes.iter().any(|n| n.id == "http://ex#Alice"));
970        assert!(
971            payload.edges.iter().any(|e| e.kind == "type" || e.kind == "assertion"),
972            "expected type/assertion edges: {:?}",
973            payload.edges
974        );
975    }
976
977    #[test]
978    fn query_result_graph_seeds_roots() {
979        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
980        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
981        let mut req = default_request("query_result");
982        req.root_iris = vec![
983            "http://example.org/clinic#Patient".to_string(),
984            "http://example.org/clinic#MedicalRecord".to_string(),
985        ];
986        let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
987        assert!(payload.nodes.iter().any(|n| n.id.contains("Patient")));
988        assert_eq!(payload.graph_kind, "query_result");
989    }
990
991    #[test]
992    fn neighborhood_graph_includes_restriction_fillers_from_axioms() {
993        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
994        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
995        let patient = "http://example.org/clinic#Patient";
996        let record = "http://example.org/clinic#MedicalRecord";
997        let mut req = default_request("neighborhood");
998        req.root_iri = Some(patient.to_string());
999        let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
1000        assert!(
1001            payload.edges.iter().any(|e| e.source == patient && e.target == record),
1002            "expected Patient -> MedicalRecord restriction edge"
1003        );
1004    }
1005
1006    #[test]
1007    fn ontology_iri_filter_excludes_unknown_entities() {
1008        let dir = tempfile::tempdir().expect("tempdir");
1009        std::fs::write(
1010            dir.path().join("local.ttl"),
1011            "@prefix ex: <http://ex#> .\n\
1012             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
1013             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
1014             <http://ex/onto> a owl:Ontology .\n\
1015             ex:Child a owl:Class ; rdfs:subClassOf <http://external.example/Parent> .\n",
1016        )
1017        .expect("write");
1018        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
1019        let ont_id = catalog.data().documents.first().map(|d| d.id.clone()).expect("doc");
1020        let unfiltered =
1021            GraphBuilder::new(&catalog).build(&default_request("class")).expect("unfiltered");
1022        assert!(
1023            unfiltered.nodes.iter().any(|n| n.id == "http://external.example/Parent"),
1024            "unknown parent should appear without ontology filter"
1025        );
1026
1027        let mut filtered_req = default_request("class");
1028        filtered_req.filters =
1029            GraphFilters { ontology_iri: Some(ont_id), ..GraphFilters::default() };
1030        let filtered = GraphBuilder::new(&catalog).build(&filtered_req).expect("filtered");
1031        assert!(
1032            filtered.nodes.iter().all(|n| n.id != "http://external.example/Parent"),
1033            "unknown parent must not bypass ontology_iri filter"
1034        );
1035        assert!(
1036            filtered.edges.iter().all(|e| {
1037                e.source != "http://external.example/Parent"
1038                    && e.target != "http://external.example/Parent"
1039            }),
1040            "edges to unknown parent must be excluded when filtering"
1041        );
1042    }
1043
1044    #[test]
1045    fn search_text_and_entity_kind_filters() {
1046        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
1047        let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
1048        let mut req = default_request("class");
1049        req.filters.search_text = Some("Patient".to_string());
1050        req.filters.entity_kinds = vec!["class".to_string()];
1051        let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
1052        assert!(payload.nodes.iter().all(|n| {
1053            n.kind == "class"
1054                && (n.label.to_lowercase().contains("patient")
1055                    || n.id.to_lowercase().contains("patient"))
1056        }));
1057    }
1058
1059    #[test]
1060    fn large_graph_truncation_cap_is_honored() {
1061        let dir = tempfile::tempdir().expect("tempdir");
1062        let mut ttl = String::from(
1063            "@prefix ex: <http://ex#> .\n\
1064             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
1065             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
1066             <http://ex/onto> a owl:Ontology .\n\
1067             ex:Root a owl:Class .\n",
1068        );
1069        // Enough subclass edges that truncation may trigger under low test caps —
1070        // assert non-empty and that builder completes quickly for a sizable hierarchy.
1071        for i in 0..200 {
1072            ttl.push_str(&format!("ex:C{i} a owl:Class ; rdfs:subClassOf ex:Root .\n"));
1073        }
1074        std::fs::write(dir.path().join("large.ttl"), ttl).expect("write");
1075        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
1076        let started = std::time::Instant::now();
1077        let payload = GraphBuilder::new(&catalog).build(&default_request("class")).expect("graph");
1078        assert!(started.elapsed().as_secs() < 5, "class graph build too slow");
1079        assert!(!payload.nodes.is_empty());
1080        assert!(payload.nodes.len() <= MAX_GRAPH_NODES);
1081        assert!(payload.edges.len() <= MAX_GRAPH_EDGES);
1082    }
1083}