Skip to main content

qualia_core_db/modalities/logic/owl/
shacl_convert.rs

1//! OWL → SHACL conversion for QualiaDB ontology alignment.
2//!
3//! RadLex and DICOM healthcare vocabularies arrive as OWL (RDF/XML or Turtle/N3).
4//! The Webizen Sentinel consumes **SHACL**, not OWL classifiers directly. This module
5//! lowers OWL axioms into `sh:NodeShape` graphs while preserving the agency invariant:
6//! **`q42:Principal` may have `q42:Thing` possessions but is not itself a Thing.**
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs::{self, File};
10use std::io::{BufReader, Write};
11use std::path::Path;
12
13use quick_xml::events::Event;
14use quick_xml::Reader;
15use rio_api::model::Triple;
16use rio_api::parser::TriplesParser;
17use rio_turtle::TurtleParser;
18
19const OWL_CLASS: &str = "http://www.w3.org/2002/07/owl#Class";
20const OWL_DATATYPE_PROPERTY: &str = "http://www.w3.org/2002/07/owl#DatatypeProperty";
21const OWL_OBJECT_PROPERTY: &str = "http://www.w3.org/2002/07/owl#ObjectProperty";
22const OWL_EQUIVALENT_CLASS: &str = "http://www.w3.org/2002/07/owl#equivalentClass";
23const OWL_EQUIVALENT_PROPERTY: &str = "http://www.w3.org/2002/07/owl#equivalentProperty";
24const RDFS_DOMAIN: &str = "http://www.w3.org/2000/01/rdf-schema#domain";
25const RDFS_RANGE: &str = "http://www.w3.org/2000/01/rdf-schema#range";
26const RDFS_LABEL: &str = "http://www.w3.org/2000/01/rdf-schema#label";
27const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
28
29/// Prefixes emitted on every generated shapes file.
30pub const SHAPE_PREFIXES: &str = r#"@prefix sh: <http://www.w3.org/ns/shacl#> .
31@prefix owl: <http://www.w3.org/2002/07/owl#> .
32@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
33@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
34@prefix q42: <https://ns.webcivics.net/> .
35@prefix hc: <http://purl.org/healthcarevocab/v1#> .
36@prefix radlex: <http://www.radlex.org/RID/> .
37"#;
38
39#[derive(Debug, Default, Clone)]
40struct OwlClass {
41    labels: Vec<String>,
42    equivalent: Option<String>,
43}
44
45#[derive(Debug, Default, Clone)]
46struct OwlProperty {
47    labels: Vec<String>,
48    kinds: BTreeSet<String>,
49    domains: BTreeSet<String>,
50    ranges: BTreeSet<String>,
51    equivalent: Option<String>,
52}
53
54#[derive(Debug, Default)]
55pub struct HealthcareOwlModel {
56    classes: BTreeMap<String, OwlClass>,
57    properties: BTreeMap<String, OwlProperty>,
58}
59
60#[derive(Debug, Clone)]
61pub struct RadlexRelation {
62    pub subject_rid: String,
63    pub predicate_local: String,
64    pub object_rid: String,
65}
66
67#[derive(Debug, PartialEq, Eq)]
68pub enum OwlToShaclError {
69    Io(String),
70    Parse(String),
71    EmptyInput,
72}
73
74impl std::fmt::Display for OwlToShaclError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Io(msg) => write!(f, "IO error: {msg}"),
78            Self::Parse(msg) => write!(f, "parse error: {msg}"),
79            Self::EmptyInput => write!(f, "no triples or relations parsed"),
80        }
81    }
82}
83
84impl std::error::Error for OwlToShaclError {}
85
86fn is_blank_or_anon(subject: &str) -> bool {
87    subject.starts_with("_:") || subject.starts_with('[')
88}
89
90fn local_name(uri: &str) -> String {
91    uri.rsplit(['#', '/']).next().unwrap_or(uri).to_string()
92}
93
94fn shape_name_for_uri(uri: &str) -> String {
95    let local = local_name(uri);
96    if uri.contains("healthcarevocab") {
97        format!("hc:{local}Shape")
98    } else if uri.contains("radlex.org") {
99        format!("radlex:{local}Shape")
100    } else {
101        format!("ex:{local}Shape")
102    }
103}
104
105fn curie_for_uri(uri: &str) -> String {
106    if let Some(tag) = uri.strip_prefix("http://purl.org/healthcarevocab/v1#") {
107        return format!("hc:{tag}");
108    }
109    if let Some(tag) = uri.strip_prefix("http://www.radlex.org/RID/") {
110        return format!("radlex:{tag}");
111    }
112    if uri.starts_with("https://ns.webcivics.net/") {
113        let tag = uri.trim_start_matches("https://ns.webcivics.net/");
114        return format!("q42:{tag}");
115    }
116    format!("<{uri}>")
117}
118
119fn xsd_from_range(range: &str) -> Option<&'static str> {
120    match range {
121        "http://www.w3.org/2001/XMLSchema#date" => Some("xsd:date"),
122        "http://www.w3.org/2001/XMLSchema#dateTime" => Some("xsd:dateTime"),
123        "http://www.w3.org/2001/XMLSchema#long" | "http://www.w3.org/2001/XMLSchema#integer" => {
124            Some("xsd:integer")
125        }
126        "http://www.w3.org/2001/XMLSchema#double"
127        | "http://www.w3.org/2001/XMLSchema#decimal"
128        | "http://www.w3.org/2001/XMLSchema#float" => Some("xsd:decimal"),
129        "http://www.w3.org/2001/XMLSchema#boolean" => Some("xsd:boolean"),
130        _ => None,
131    }
132}
133
134fn ingest_owl_triple(model: &mut HealthcareOwlModel, s: String, p: String, o: String) {
135    if p == RDF_TYPE {
136        if o == OWL_CLASS {
137            model.classes.entry(s).or_default();
138        } else if o == OWL_DATATYPE_PROPERTY || o == OWL_OBJECT_PROPERTY {
139            model.properties.entry(s).or_default().kinds.insert(o);
140        }
141    } else if p == RDFS_LABEL {
142        if let Some(class) = model.classes.get_mut(&s) {
143            class.labels.push(o.trim_matches('"').to_string());
144        } else if let Some(prop) = model.properties.get_mut(&s) {
145            prop.labels.push(o.trim_matches('"').to_string());
146        }
147    } else if p == OWL_EQUIVALENT_CLASS {
148        model.classes.entry(s).or_default().equivalent = Some(o);
149    } else if p == OWL_EQUIVALENT_PROPERTY {
150        model.properties.entry(s).or_default().equivalent = Some(o);
151    } else if p == RDFS_DOMAIN {
152        model.properties.entry(s).or_default().domains.insert(o);
153    } else if p == RDFS_RANGE {
154        model.properties.entry(s).or_default().ranges.insert(o);
155    }
156}
157
158/// Parse Turtle healthcare vocabulary.
159pub fn parse_healthcare_owl_turtle(path: &Path) -> Result<HealthcareOwlModel, OwlToShaclError> {
160    let bytes = fs::read(path).map_err(|e| OwlToShaclError::Io(e.to_string()))?;
161    let sanitized = trim_incomplete_turtle_tail(&bytes);
162    let mut model = HealthcareOwlModel::default();
163    let mut on_triple = |t: Triple| -> Result<(), std::io::Error> {
164        ingest_owl_triple(
165            &mut model,
166            t.subject.to_string(),
167            t.predicate.to_string(),
168            t.object.to_string(),
169        );
170        Ok(())
171    };
172    let cursor = std::io::Cursor::new(sanitized);
173    let mut parser = TurtleParser::new(cursor, None);
174    if let Err(e) = parser.parse_all(&mut on_triple) {
175        if model.classes.is_empty() && model.properties.is_empty() {
176            return Err(OwlToShaclError::Parse(format!("{e}")));
177        }
178    }
179    if model.classes.is_empty() && model.properties.is_empty() {
180        return Err(OwlToShaclError::EmptyInput);
181    }
182    Ok(model)
183}
184
185/// Parse `.n3` healthcare excerpt (OWL in Notation3/Turtle syntax; tolerates truncated exports).
186pub fn parse_healthcare_owl_n3(path: &Path) -> Result<HealthcareOwlModel, OwlToShaclError> {
187    let text = std::fs::read_to_string(path).map_err(|e| OwlToShaclError::Io(e.to_string()))?;
188    let mut model = HealthcareOwlModel::default();
189    let mut parser = crate::modalities::logic::n3_parser::N3Parser::new(&text);
190    let result = parser.parse_all(|event| {
191        if let crate::modalities::logic::n3_parser::N3Event::StaticTriple(triple) = event {
192            let subject = term_uri(&triple.subject);
193            let predicate = term_uri(&triple.predicate);
194            let object = term_uri(&triple.object);
195            ingest_owl_triple(&mut model, subject, predicate, object);
196        }
197        Ok(())
198    });
199    if result.is_err() && model.classes.is_empty() && model.properties.is_empty() {
200        return parse_healthcare_owl_turtle(path);
201    }
202    if model.classes.is_empty() && model.properties.is_empty() {
203        return Err(OwlToShaclError::EmptyInput);
204    }
205    Ok(model)
206}
207
208/// Line-oriented parser for healthcare vocab exports (handles truncated tails and `unionOf` domains).
209pub fn parse_healthcare_owl_lines(path: &Path) -> Result<HealthcareOwlModel, OwlToShaclError> {
210    let bytes = fs::read(path).map_err(|e| OwlToShaclError::Io(e.to_string()))?;
211    let sanitized = trim_incomplete_turtle_tail(&bytes);
212    let text = String::from_utf8_lossy(&sanitized).into_owned();
213    let mut model = HealthcareOwlModel::default();
214    let mut current_subject: Option<String> = None;
215
216    for raw in text.lines() {
217        let line = raw.trim();
218        if line.is_empty() {
219            continue;
220        }
221        if line.starts_with('@') {
222            continue;
223        }
224        if line.starts_with('<')
225            && line.ends_with('>')
226            && !line.contains(';')
227            && !line.contains(' ')
228        {
229            current_subject = Some(line.trim_matches(|c| c == '<' || c == '>').to_string());
230            continue;
231        }
232        let Some(subject) = current_subject.clone() else {
233            continue;
234        };
235        if let Some(kind) = line.strip_prefix("a ") {
236            let kind = kind.trim().trim_end_matches(';').trim();
237            let expanded = match kind {
238                "owl:Class" => OWL_CLASS.to_string(),
239                "owl:DatatypeProperty" => OWL_DATATYPE_PROPERTY.to_string(),
240                "owl:ObjectProperty" => OWL_OBJECT_PROPERTY.to_string(),
241                other if other.starts_with("http") => other.to_string(),
242                _ => continue,
243            };
244            ingest_owl_triple(&mut model, subject, RDF_TYPE.to_string(), expanded);
245        } else if line.strip_prefix("rdfs:label").is_some() {
246            if let Some(value) = extract_turtle_string_literal(line) {
247                ingest_owl_triple(&mut model, subject, RDFS_LABEL.to_string(), value);
248            }
249        } else if line.contains("owl:equivalentClass") {
250            if let Some(uri) = extract_first_uri(line) {
251                ingest_owl_triple(&mut model, subject, OWL_EQUIVALENT_CLASS.to_string(), uri);
252            }
253        } else if line.contains("owl:equivalentProperty") {
254            if let Some(uri) = extract_first_uri(line) {
255                ingest_owl_triple(
256                    &mut model,
257                    subject,
258                    OWL_EQUIVALENT_PROPERTY.to_string(),
259                    uri,
260                );
261            }
262        } else if line.contains("rdfs:domain") {
263            if let Some(uri) = extract_first_uri(line) {
264                ingest_owl_triple(&mut model, subject, RDFS_DOMAIN.to_string(), uri);
265            }
266        } else if line.contains("rdfs:range") {
267            if let Some(uri) = extract_first_uri(line) {
268                ingest_owl_triple(&mut model, subject, RDFS_RANGE.to_string(), uri);
269            }
270        } else if line.ends_with('.') && !line.starts_with('<') {
271            current_subject = None;
272        }
273    }
274
275    if model.classes.is_empty() && model.properties.is_empty() {
276        return Err(OwlToShaclError::EmptyInput);
277    }
278    Ok(model)
279}
280
281fn extract_first_uri(line: &str) -> Option<String> {
282    let start = line.find('<')? + 1;
283    let end = line[start..].find('>')? + start;
284    Some(line[start..end].to_string())
285}
286
287fn extract_turtle_string_literal(line: &str) -> Option<String> {
288    let start = line.find('"')? + 1;
289    let rest = &line[start..];
290    let end = rest.find('"')?;
291    Some(rest[..end].to_string())
292}
293
294/// Parse healthcare OWL from `.n3` or `.ttl`.
295pub fn parse_healthcare_owl(path: &Path) -> Result<HealthcareOwlModel, OwlToShaclError> {
296    parse_healthcare_owl_lines(path)
297        .or_else(|_| parse_healthcare_owl_turtle(path))
298        .or_else(|_| parse_healthcare_owl_n3(path))
299}
300
301fn term_uri(term: &crate::modalities::logic::n3_parser::Term) -> String {
302    match term {
303        crate::modalities::logic::n3_parser::Term::Uri(s) => s.to_string(),
304        crate::modalities::logic::n3_parser::Term::Variable(s) => s.to_string(),
305        crate::modalities::logic::n3_parser::Term::Literal(s) => s.to_string(),
306        crate::modalities::logic::n3_parser::Term::Formula(s) => s.to_string(),
307    }
308}
309
310/// Drop a truncated final subject line (common in partial DICOM OWL exports).
311fn trim_incomplete_turtle_tail(bytes: &[u8]) -> Vec<u8> {
312    let text = String::from_utf8_lossy(bytes);
313    if text.trim_end().ends_with('.') {
314        return bytes.to_vec();
315    }
316    let mut lines: Vec<&str> = text.lines().collect();
317    while let Some(last) = lines.last() {
318        if last.trim().starts_with('<') && !last.trim().ends_with('.') {
319            lines.pop();
320        } else {
321            break;
322        }
323    }
324    let mut out = lines.join("\n");
325    if !out.ends_with('\n') {
326        out.push('\n');
327    }
328    out.into_bytes()
329}
330
331/// Emit SHACL node shapes for healthcare IE.* classes and DICOM-tag properties.
332pub fn healthcare_owl_to_shacl_ttl(model: &HealthcareOwlModel) -> String {
333    let mut out = String::from(SHAPE_PREFIXES);
334    out.push_str("\n# Generated from healthcare OWL — DICOM tag alignment\n\n");
335
336    for (class_uri, class) in &model.classes {
337        if is_blank_or_anon(class_uri) {
338            continue;
339        }
340        let local = local_name(class_uri);
341        if !local.starts_with("IE.") && !local.starts_with("SequenceItem.") {
342            continue;
343        }
344        let shape = shape_name_for_uri(class_uri);
345        let target = curie_for_uri(class_uri);
346        out.push_str(&format!(
347            "{shape} a sh:NodeShape ;\n    sh:targetClass {target}"
348        ));
349        if let Some(label) = class.labels.first() {
350            out.push_str(&format!(" ;\n    rdfs:label \"{label}\""));
351        }
352        if let Some(eq) = &class.equivalent {
353            out.push_str(&format!(
354                " ;\n    sh:property [ sh:path owl:equivalentClass ; sh:hasValue {} ]",
355                curie_for_uri(eq)
356            ));
357        }
358        out.push_str(" .\n\n");
359    }
360
361    let mut by_domain: BTreeMap<String, Vec<(String, OwlProperty)>> = BTreeMap::new();
362    for (prop_uri, prop) in &model.properties {
363        if is_blank_or_anon(prop_uri) {
364            continue;
365        }
366        let domains: Vec<String> = if prop.domains.is_empty() {
367            vec!["http://purl.org/healthcarevocab/v1#IE.Image".to_string()]
368        } else {
369            prop.domains.iter().cloned().collect()
370        };
371        for domain in domains {
372            if is_blank_or_anon(&domain) {
373                continue;
374            }
375            by_domain
376                .entry(domain)
377                .or_default()
378                .push((prop_uri.clone(), prop.clone()));
379        }
380    }
381
382    for (domain_uri, props) in by_domain {
383        let domain_local = local_name(&domain_uri);
384        if !domain_local.starts_with("IE.") {
385            continue;
386        }
387        let shape = format!("hc:{domain_local}PropertyShape");
388        let target = curie_for_uri(&domain_uri);
389        out.push_str(&format!(
390            "{shape} a sh:NodeShape ;\n    sh:targetClass {target}"
391        ));
392        for (prop_uri, prop) in props {
393            let path = curie_for_uri(&prop_uri);
394            out.push_str(" ;\n    sh:property [");
395            out.push_str(&format!("\n        sh:path {path}"));
396            if let Some(label) = prop.labels.first() {
397                out.push_str(&format!(" ;\n        sh:name \"{label}\""));
398            }
399            if let Some(eq) = &prop.equivalent {
400                out.push_str(&format!(
401                    " ;\n        sh:qualifiedValueShape [ sh:hasValue {} ]",
402                    curie_for_uri(eq)
403                ));
404            }
405            if prop.kinds.contains(OWL_DATATYPE_PROPERTY) {
406                if let Some(range) = prop.ranges.iter().next() {
407                    if let Some(dt) = xsd_from_range(range) {
408                        out.push_str(&format!(" ;\n        sh:datatype {dt}"));
409                    } else if *range == "http://www.w3.org/2000/01/rdf-schema#Literal" {
410                        out.push_str(" ;\n        sh:datatype xsd:string");
411                    }
412                }
413            } else if prop.kinds.contains(OWL_OBJECT_PROPERTY) {
414                out.push_str(" ;\n        sh:nodeKind sh:BlankNodeOrIRI");
415            }
416            out.push_str("\n    ]");
417        }
418        out.push_str(" .\n\n");
419    }
420
421    out.push_str(
422        "# Principal may reference imaging entities but is not an IE.* class.\n\
423q42:PrincipalImagingLinkShape a sh:NodeShape ;\n\
424    sh:targetClass q42:Principal ;\n\
425    sh:property [\n\
426        sh:path q42:hasImagingStudy ;\n\
427        sh:class hc:IE.Study ;\n\
428        sh:minCount 0 ;\n\
429        sh:message \"Imaging studies are possessions of the Principal, not the Principal itself.\" ;\n\
430    ] ;\n\
431    sh:property [\n\
432        sh:path q42:hasDicomSeries ;\n\
433        sh:class hc:IE.Series ;\n\
434        sh:minCount 0 ;\n\
435    ] .\n\n",
436    );
437
438    out
439}
440
441/// Stream-parse RadLex Pun OWL (RDF/XML) relation axioms (`RID:Part_Of`, `RID:Has_Part`, …).
442pub fn parse_radlex_relations_xml(
443    path: &Path,
444    max_relations: usize,
445) -> Result<Vec<RadlexRelation>, OwlToShaclError> {
446    let file = File::open(path).map_err(|e| OwlToShaclError::Io(e.to_string()))?;
447    let mut reader = Reader::from_reader(BufReader::new(file));
448    reader.config_mut().trim_text(true);
449
450    let mut relations = Vec::new();
451    let mut buf = Vec::new();
452    let mut current_subject: Option<String> = None;
453    let mut in_description = false;
454
455    loop {
456        match reader.read_event_into(&mut buf) {
457            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
458                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
459                if name == "rdf:Description" || name.ends_with("Description") {
460                    in_description = true;
461                    current_subject = None;
462                    for attr in e.attributes().flatten() {
463                        let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
464                        if key.ends_with("about") {
465                            current_subject =
466                                Some(String::from_utf8_lossy(&attr.value).to_string());
467                        }
468                    }
469                } else if in_description {
470                    if let Some(subject) = current_subject.clone() {
471                        if let Some(pred_local) = name.strip_prefix("RID:") {
472                            for attr in e.attributes().flatten() {
473                                let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
474                                if key.ends_with("resource") {
475                                    let object = String::from_utf8_lossy(&attr.value).to_string();
476                                    relations.push(RadlexRelation {
477                                        subject_rid: subject.clone(),
478                                        predicate_local: pred_local.to_string(),
479                                        object_rid: object,
480                                    });
481                                    if relations.len() >= max_relations {
482                                        return Ok(relations);
483                                    }
484                                }
485                            }
486                        }
487                    }
488                }
489            }
490            Ok(Event::End(e)) => {
491                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
492                if name == "rdf:Description" || name.ends_with("Description") {
493                    in_description = false;
494                    current_subject = None;
495                }
496            }
497            Ok(Event::Eof) => break,
498            Err(e) => return Err(OwlToShaclError::Parse(format!("{e}"))),
499            _ => {}
500        }
501        buf.clear();
502    }
503
504    if relations.is_empty() {
505        return Err(OwlToShaclError::EmptyInput);
506    }
507    Ok(relations)
508}
509
510/// Group RadLex relations by subject RID and emit SHACL node shapes.
511pub fn radlex_relations_to_shacl_ttl(relations: &[RadlexRelation], max_shapes: usize) -> String {
512    let mut grouped: BTreeMap<String, Vec<RadlexRelation>> = BTreeMap::new();
513    for rel in relations {
514        grouped
515            .entry(rel.subject_rid.clone())
516            .or_default()
517            .push(rel.clone());
518    }
519
520    let mut out = String::from(SHAPE_PREFIXES);
521    out.push_str(
522        "\n# Generated from RadLex OWL — anatomical possession graph (Things, not Principals)\n\n",
523    );
524    out.push_str(
525        "radlex:AnatomicalEntity a owl:Class ;\n\
526    rdfs:subClassOf q42:Thing ;\n\
527    rdfs:comment \"RadLex RID tokens are clinical Things a Principal may have findings about.\" .\n\n",
528    );
529
530    for (idx, (subject, rels)) in grouped.iter().enumerate() {
531        if idx >= max_shapes {
532            out.push_str(&format!(
533                "# ... truncated ({max_shapes} shapes shown; increase max_shapes for full export)\n"
534            ));
535            break;
536        }
537        let rid_local = local_name(subject);
538        let shape = format!("radlex:{rid_local}Shape");
539        let target = curie_for_uri(subject);
540        out.push_str(&format!(
541            "{shape} a sh:NodeShape ;\n    sh:targetClass {target} ;\n    sh:class radlex:AnatomicalEntity"
542        ));
543        for rel in rels {
544            let pred = format!("radlex:{}", rel.predicate_local);
545            let obj = curie_for_uri(&rel.object_rid);
546            out.push_str(&format!(
547                " ;\n    sh:property [ sh:path {pred} ; sh:hasValue {obj} ]"
548            ));
549        }
550        out.push_str(" .\n\n");
551    }
552
553    out.push_str(
554        "q42:PrincipalRadLexFindingShape a sh:NodeShape ;\n\
555    sh:targetClass q42:Principal ;\n\
556    sh:property [\n\
557        sh:path q42:hasFinding ;\n\
558        sh:class radlex:AnatomicalEntity ;\n\
559        sh:minCount 0 ;\n\
560        sh:message \"RadLex findings attach to a Principal; the Principal is not an anatomical entity.\" ;\n\
561    ] .\n\n",
562    );
563
564    out
565}
566
567/// Write agency baseline + converted ontologies to an output directory.
568pub fn write_anatomy_shape_bundle(
569    healthcare_owl: Option<&Path>,
570    radlex_owl: Option<&Path>,
571    out_dir: &Path,
572    radlex_max_shapes: usize,
573    radlex_max_relations: usize,
574) -> Result<Vec<String>, OwlToShaclError> {
575    fs::create_dir_all(out_dir).map_err(|e| OwlToShaclError::Io(e.to_string()))?;
576    let mut written = Vec::new();
577
578    let agency_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("shapes/qualia-agency.shacl.ttl");
579    let agency_dst = out_dir.join("qualia-agency.shacl.ttl");
580    fs::copy(&agency_src, &agency_dst).map_err(|e| OwlToShaclError::Io(e.to_string()))?;
581    written.push(agency_dst.display().to_string());
582
583    if let Some(path) = healthcare_owl {
584        let model = parse_healthcare_owl(path)?;
585        let ttl = healthcare_owl_to_shacl_ttl(&model);
586        let dst = out_dir.join("dicom-healthcare.shacl.ttl");
587        write_text(&dst, &ttl)?;
588        written.push(dst.display().to_string());
589    }
590
591    if let Some(path) = radlex_owl {
592        let relations = parse_radlex_relations_xml(path, radlex_max_relations)?;
593        let ttl = radlex_relations_to_shacl_ttl(&relations, radlex_max_shapes);
594        let dst = out_dir.join("radlex-anatomy.shacl.ttl");
595        write_text(&dst, &ttl)?;
596        written.push(dst.display().to_string());
597    }
598
599    Ok(written)
600}
601
602fn write_text(path: &Path, content: &str) -> Result<(), OwlToShaclError> {
603    let mut f = File::create(path).map_err(|e| OwlToShaclError::Io(e.to_string()))?;
604    f.write_all(content.as_bytes())
605        .map_err(|e| OwlToShaclError::Io(e.to_string()))
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn fixture_healthcare() -> Option<std::path::PathBuf> {
613        let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
614        for candidate in [
615            manifest.join("../../app-development/2015-01-11.n3"),
616            std::path::PathBuf::from("app-development/2015-01-11.n3"),
617        ] {
618            if candidate.is_file() {
619                return candidate.canonicalize().ok().or(Some(candidate));
620            }
621        }
622        None
623    }
624
625    fn fixture_radlex() -> Option<std::path::PathBuf> {
626        let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
627        for candidate in [
628            manifest.join("../../app-development/PunRadLex_Owl4.3/PunRadLex4.3.owl"),
629            std::path::PathBuf::from("app-development/PunRadLex_Owl4.3/PunRadLex4.3.owl"),
630        ] {
631            if candidate.is_file() {
632                return candidate.canonicalize().ok().or(Some(candidate));
633            }
634        }
635        None
636    }
637
638    #[test]
639    fn healthcare_owl_parses_and_emits_ie_shapes() {
640        let Some(path) = fixture_healthcare() else {
641            eprintln!("skip: 2015-01-11.n3 not present");
642            return;
643        };
644        let model = parse_healthcare_owl(&path).expect("parse healthcare owl");
645        assert!(!model.classes.is_empty());
646        assert!(!model.properties.is_empty());
647        let ttl = healthcare_owl_to_shacl_ttl(&model);
648        assert!(ttl.contains("hc:IE.ImagePropertyShape"));
649        assert!(ttl.contains("q42:PrincipalImagingLinkShape"));
650        assert!(ttl.contains("sh:targetClass q42:Principal"));
651    }
652
653    #[test]
654    fn radlex_xml_parses_part_of_relations() {
655        let Some(path) = fixture_radlex() else {
656            eprintln!("skip: PunRadLex4.3.owl not present");
657            return;
658        };
659        let rels = parse_radlex_relations_xml(&path, 64).expect("parse radlex");
660        assert!(!rels.is_empty());
661        assert!(rels.iter().any(|r| r.predicate_local == "Part_Of"));
662        let ttl = radlex_relations_to_shacl_ttl(&rels, 8);
663        assert!(ttl.contains("radlex:AnatomicalEntity"));
664        assert!(ttl.contains("q42:PrincipalRadLexFindingShape"));
665        assert!(ttl.contains("sh:targetClass q42:Principal"));
666    }
667
668    #[test]
669    fn agency_shape_file_exists() {
670        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
671            .join("shapes/qualia-agency.shacl.ttl");
672        let text = fs::read_to_string(p).unwrap();
673        assert!(text.contains("q42:PrincipalShape"));
674        assert!(text.contains("sh:not"));
675        assert!(text.contains("q42:hasCondition"));
676    }
677
678    /// Regression guard for the human-centric PRIMITIVE: a natural person must be
679    /// grounded in RDFS (rdfs:Class), never in OWL (owl:Class) — because under OWL
680    /// semantics every owl:Class individual is implicitly an owl:Thing, which reduces
681    /// a person to a "thing". This invariant was reverted once by a careless agent and
682    /// only caught by manual inspection; this test stops that recurring silently.
683    #[test]
684    fn principal_is_rdfs_grounded_not_owl_thing() {
685        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
686            .join("shapes/qualia-agency.shacl.ttl");
687        let text = fs::read_to_string(p).unwrap();
688
689        // The Principal (and the native q42 classes) MUST be rdfs:Class, never owl:Class.
690        assert!(
691            text.contains("q42:Principal a rdfs:Class"),
692            "q42:Principal must be grounded as rdfs:Class (a natural person is not an owl:Thing)"
693        );
694        assert!(
695            !text.contains("q42:Principal a owl:Class"),
696            "q42:Principal must NOT be declared a owl:Class — that re-imports owl:Thing semantics"
697        );
698
699        // owl: may appear ONLY as a guard target (sh:not owl:Thing), never as a person's type.
700        assert!(
701            text.contains("A Principal must not be typed as owl:Thing"),
702            "the sh:not owl:Thing dignity guard must remain"
703        );
704
705        // The native vocabulary uses rdf:Property, not owl:ObjectProperty.
706        assert!(
707            !text.contains("a owl:ObjectProperty"),
708            "native possession relations must be rdf:Property, not owl:ObjectProperty"
709        );
710    }
711
712    /// The `.q42` format lexicon must register SHACL (`sh`) as a first-class prefix —
713    /// SHACL is the enforcement layer for the human-centric primitive, so the format
714    /// must be able to name it (it was previously omitted while owl: was registered).
715    #[test]
716    fn q42_lexicon_registers_shacl_prefix() {
717        let ctx = crate::q42_lexicon::Q42Context::new();
718        assert_eq!(
719            ctx.vocabulary.get("sh").map(String::as_str),
720            Some("http://www.w3.org/ns/shacl#"),
721            "q42 context must register the SHACL (sh:) namespace"
722        );
723        assert_eq!(
724            ctx.vocabulary.get("hcai").map(String::as_str),
725            Some("http://www.w3.org/ns/hcai#"),
726            "q42 context must register the human-centric (hcai:) namespace"
727        );
728    }
729}