qualia_core_db/modalities/logic/owl/
shacl_convert.rs1use 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
29pub 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
158pub 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
185pub 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
208pub 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
294pub 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
310fn 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
331pub 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
441pub 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
510pub 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
567pub 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 #[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 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 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 assert!(
707 !text.contains("a owl:ObjectProperty"),
708 "native possession relations must be rdf:Property, not owl:ObjectProperty"
709 );
710 }
711
712 #[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}