@@ -644,24 +644,84 @@ impl OwlValidatorService {
644644 format ! ( "ontology_{}" , self . calculate_signature( source) )
645645 }
646646
647+ /// Expand a graph identifier into an absolute IRI.
648+ ///
649+ /// Decision rule (in order):
650+ /// 1. Any string containing `://` is an absolute IRI (hierarchical scheme) → pass through.
651+ /// 2. If the substring before the first `:` is a *registered* short CURIE prefix
652+ /// (rdf, rdfs, owl, xsd, foaf, …) → expand `prefix:local` to `namespace + local`.
653+ /// 3. Else if the string looks like an absolute IRI — a well-known non-hierarchical
654+ /// scheme (urn, did, http, https, ftp, ftps, mailto, tag, file, data) OR a generic
655+ /// RFC 3986 scheme followed by a multi-segment remainder (e.g. `scheme:a:b`) →
656+ /// pass through unchanged.
657+ /// 4. If there is no `:` at all, treat it as a bare local name under the default namespace.
658+ /// 5. Otherwise the prefix is neither a registered CURIE nor a recognised absolute scheme
659+ /// → `Unknown prefix` error.
647660 fn expand_iri ( & self , iri : & str ) -> Result < String > {
661+ // (1) Hierarchical absolute IRI (scheme://authority/...) — always absolute.
648662 if iri. contains ( "://" ) {
649-
650- Ok ( iri. to_string ( ) )
651- } else if let Some ( colon_pos) = iri. find ( ':' ) {
652-
653- let ( prefix, local) = iri. split_at ( colon_pos) ;
654- let local = & local[ 1 ..] ;
663+ return Ok ( iri. to_string ( ) ) ;
664+ }
655665
656- if let Some ( namespace) = self . default_namespaces . get ( prefix) {
657- Ok ( format ! ( "{}{}" , namespace, local) )
658- } else {
666+ match iri. find ( ':' ) {
667+ Some ( colon_pos) => {
668+ let ( prefix, rest) = iri. split_at ( colon_pos) ;
669+ let local = & rest[ 1 ..] ;
670+
671+ // (2) Registered short CURIE prefix → expand.
672+ if let Some ( namespace) = self . default_namespaces . get ( prefix) {
673+ return Ok ( format ! ( "{}{}" , namespace, local) ) ;
674+ }
675+
676+ // (3) Recognised absolute-IRI scheme (urn:, did:, http:, …) → pass through.
677+ if Self :: is_absolute_iri_scheme ( prefix, local) {
678+ return Ok ( iri. to_string ( ) ) ;
679+ }
680+
681+ // (5) Unknown prefix that is neither a CURIE nor an absolute IRI.
659682 Err ( ValidationError :: InvalidIri ( format ! ( "Unknown prefix: {}" , prefix) ) . into ( ) )
660683 }
661- } else {
662-
663- Ok ( format ! ( "http://example.org/{}" , iri) )
684+ // (4) Bare local name → default namespace.
685+ None => Ok ( format ! ( "http://example.org/{}" , iri) ) ,
686+ }
687+ }
688+
689+ /// Decide whether a `prefix:local` pair (already known **not** to use a registered
690+ /// CURIE prefix) should be treated as an absolute IRI rather than an error.
691+ ///
692+ /// Returns `true` when either:
693+ /// * `prefix` is a well-known non-hierarchical absolute-IRI scheme
694+ /// (urn, did, http, https, ftp, ftps, mailto, tag, file, data), or
695+ /// * `prefix` is a syntactically valid RFC 3986 scheme AND the remainder is
696+ /// itself multi-segment (contains a further `:`), which is the shape of
697+ /// `urn`-style absolute IRIs such as `scheme:a:b`.
698+ ///
699+ /// A bare `unregistered:thing` (single-segment remainder, unknown scheme) returns
700+ /// `false` so the caller can raise the existing `Unknown prefix` error.
701+ fn is_absolute_iri_scheme ( prefix : & str , local : & str ) -> bool {
702+ const KNOWN_ABSOLUTE_SCHEMES : & [ & str ] = & [
703+ "urn" , "did" , "http" , "https" , "ftp" , "ftps" , "mailto" , "tag" , "file" , "data" ,
704+ ] ;
705+
706+ let scheme = prefix. to_ascii_lowercase ( ) ;
707+ if KNOWN_ABSOLUTE_SCHEMES . contains ( & scheme. as_str ( ) ) {
708+ return true ;
664709 }
710+
711+ // Generic RFC 3986 scheme grammar: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ).
712+ let valid_scheme = {
713+ let mut chars = prefix. chars ( ) ;
714+ match chars. next ( ) {
715+ Some ( c) if c. is_ascii_alphabetic ( ) => {
716+ chars. all ( |c| c. is_ascii_alphanumeric ( ) || c == '+' || c == '-' || c == '.' )
717+ }
718+ _ => false ,
719+ }
720+ } ;
721+
722+ // Multi-segment remainder distinguishes an absolute IRI (`scheme:a:b`) from a
723+ // CURIE-shaped `prefix:local`.
724+ valid_scheme && local. contains ( ':' )
665725 }
666726
667727 fn serialize_property_value (
@@ -1146,14 +1206,72 @@ mod tests {
11461206 fn test_iri_expansion ( ) {
11471207 let validator = OwlValidatorService :: new ( ) ;
11481208
1149-
1209+ // Registered CURIE prefixes still expand to their full namespace IRI.
11501210 let expanded = validator. expand_iri ( "foaf:Person" ) . unwrap ( ) ;
11511211 assert_eq ! ( expanded, "http://xmlns.com/foaf/0.1/Person" ) ;
11521212
1153-
1213+ let expanded = validator. expand_iri ( "owl:Class" ) . unwrap ( ) ;
1214+ assert_eq ! ( expanded, "http://www.w3.org/2002/07/owl#Class" ) ;
1215+
1216+ let expanded = validator. expand_iri ( "rdfs:subClassOf" ) . unwrap ( ) ;
1217+ assert_eq ! ( expanded, "http://www.w3.org/2000/01/rdf-schema#subClassOf" ) ;
1218+
1219+ // Hierarchical absolute IRIs pass through unchanged.
11541220 let full_iri = "http://example.org/Person" ;
1155- let expanded = validator. expand_iri ( full_iri) . unwrap ( ) ;
1156- assert_eq ! ( expanded, full_iri) ;
1221+ assert_eq ! ( validator. expand_iri( full_iri) . unwrap( ) , full_iri) ;
1222+ }
1223+
1224+ #[ test]
1225+ fn test_urn_iris_pass_through_as_absolute ( ) {
1226+ // Regression: real KG/corpus data is full of urn:ngm:* and urn:agentbox:* IRIs.
1227+ // These are absolute IRIs, NOT CURIEs, and must never trigger "Unknown prefix".
1228+ let validator = OwlValidatorService :: new ( ) ;
1229+
1230+ for iri in [
1231+ "urn:ngm:class:foo" ,
1232+ "urn:ngm:class:x" ,
1233+ "urn:agentbox:decision:y" ,
1234+ "urn:agentbox:decision:2026-08-08:abc" ,
1235+ ] {
1236+ let expanded = validator. expand_iri ( iri) . unwrap ( ) ;
1237+ assert_eq ! ( expanded, iri, "urn IRI must pass through unchanged: {iri}" ) ;
1238+ }
1239+ }
1240+
1241+ #[ test]
1242+ fn test_did_and_http_iris_pass_through_as_absolute ( ) {
1243+ let validator = OwlValidatorService :: new ( ) ;
1244+
1245+ for iri in [
1246+ "did:nostr:abc" ,
1247+ "http://example.org/z" ,
1248+ "https://example.org/z" ,
1249+ ] {
1250+ let expanded = validator. expand_iri ( iri) . unwrap ( ) ;
1251+ assert_eq ! ( expanded, iri, "absolute IRI must pass through unchanged: {iri}" ) ;
1252+ }
1253+ }
1254+
1255+ #[ test]
1256+ fn test_unregistered_curie_prefix_still_errors ( ) {
1257+ // A single-segment, unknown-scheme value like `bogus:thing` is neither a
1258+ // registered CURIE prefix nor an absolute-IRI shape, so it still errors.
1259+ let validator = OwlValidatorService :: new ( ) ;
1260+
1261+ assert ! (
1262+ validator. expand_iri( "bogus:thing" ) . is_err( ) ,
1263+ "unregistered bare prefix must still error"
1264+ ) ;
1265+ }
1266+
1267+ #[ test]
1268+ fn test_generic_multi_segment_scheme_is_absolute ( ) {
1269+ // An unknown but syntactically valid scheme with a multi-segment remainder
1270+ // (urn-style `scheme:a:b`) is treated as an absolute IRI, not a CURIE.
1271+ let validator = OwlValidatorService :: new ( ) ;
1272+
1273+ let iri = "myscheme:a:b" ;
1274+ assert_eq ! ( validator. expand_iri( iri) . unwrap( ) , iri) ;
11571275 }
11581276
11591277 #[ test]
0 commit comments