@@ -586,6 +586,72 @@ impl OntologySchema {
586586 leaf_count,
587587 } )
588588 }
589+
590+ /// Returns `true` iff `descendant` is reachable from `ancestor` by
591+ /// walking the `rdfs:subClassOf` chain encoded in
592+ /// [`EntityClass::parent`]. The relation is *reflexive*: every class
593+ /// is its own ancestor (including unknown IRIs — which return `true`
594+ /// only for the reflexive case `ancestor == descendant`).
595+ ///
596+ /// # Complexity
597+ ///
598+ /// O(depth) — walks at most `MAX_DEPTH = 64` parent links before
599+ /// returning `false`, defensively guarding against any cycle that
600+ /// might have slipped past the partial-order check upstream. Pillar-14
601+ /// asserts the closure is antisymmetric on synthetic DAGs; this
602+ /// method's cycle guard is the runtime backstop for that assertion.
603+ ///
604+ /// # Use case
605+ ///
606+ /// The Pillar-14 drift-check test in `crate::hpc::pillar::ogit_lattice`
607+ /// uses this method to verify the loaded ontology's closure satisfies
608+ /// the same three partial-order axioms the synthetic-DAG probe
609+ /// certifies. Beyond that, it is the natural query for any
610+ /// type-gated cascade step: "may activation propagate from a tile of
611+ /// type `descendant` to a tile of type `ancestor`?"
612+ ///
613+ /// # Example
614+ ///
615+ /// ```ignore
616+ /// // Reflexive
617+ /// assert!(schema.is_ancestor("ogit:Heel", "ogit:Heel"));
618+ /// // Transitive via the subClassOf chain
619+ /// assert!(schema.is_ancestor("ogit:Heel", "ogit:SomeLeaf"));
620+ /// // No relation
621+ /// assert!(!schema.is_ancestor("ogit:Leaf", "ogit:Heel"));
622+ /// // Unknown descendant — only reflexive case returns true
623+ /// assert!(!schema.is_ancestor("ogit:Heel", "ogit:Unknown"));
624+ /// ```
625+ pub fn is_ancestor ( & self , ancestor : & str , descendant : & str ) -> bool {
626+ // Reflexive case — handles both the "X is its own ancestor"
627+ // identity and the unknown-class case (where neither IRI is in
628+ // the schema but they are equal).
629+ if ancestor == descendant {
630+ return true ;
631+ }
632+
633+ // Walk the parent chain from descendant upward, looking for ancestor.
634+ // Defensive depth cap — see method docstring.
635+ const MAX_DEPTH : usize = 64 ;
636+ let mut current: & str = descendant;
637+ for _ in 0 ..MAX_DEPTH {
638+ let entity = match self . entities . get ( current) {
639+ Some ( e) => e,
640+ None => return false , // descendant unknown — no chain to walk
641+ } ;
642+ let parent = match entity. parent . as_deref ( ) {
643+ Some ( p) => p,
644+ None => return false , // reached root without finding ancestor
645+ } ;
646+ if parent == ancestor {
647+ return true ;
648+ }
649+ current = parent;
650+ }
651+ // Exceeded depth cap — treat as not-an-ancestor (defensive; this
652+ // path should be unreachable on a well-formed schema).
653+ false
654+ }
589655}
590656
591657// ---------------------------------------------------------------------------
@@ -775,4 +841,84 @@ mod tests {
775841 // Both bits must be set
776842 assert ! ( family. bitmap. iter( ) . all( |& b| b) , "all leaf bits must be set" ) ;
777843 }
844+
845+ // -----------------------------------------------------------------------
846+ // is_ancestor — partial-order axiom queries on the loaded closure
847+ // -----------------------------------------------------------------------
848+
849+ fn build_chain_schema ( ) -> OntologySchema {
850+ // Build a four-tier chain: Heel ← Hip ← Twig ← Leaf.
851+ let src = "\
852+ @prefix ogit: <http://www.purl.org/ogit/> .\n \
853+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n \
854+ ogit:Heel a rdfs:Class .\n \
855+ ogit:Hip a rdfs:Class ; rdfs:subClassOf ogit:Heel .\n \
856+ ogit:Twig a rdfs:Class ; rdfs:subClassOf ogit:Hip .\n \
857+ ogit:Leaf a rdfs:Class ; rdfs:subClassOf ogit:Twig .";
858+ let triples = TurtleParser :: parse ( src) . unwrap ( ) ;
859+ OntologySchema :: from_triples ( & triples) . unwrap ( )
860+ }
861+
862+ #[ test]
863+ fn is_ancestor_reflexive ( ) {
864+ let schema = build_chain_schema ( ) ;
865+ assert ! ( schema. is_ancestor( "ogit:Heel" , "ogit:Heel" ) ) ;
866+ assert ! ( schema. is_ancestor( "ogit:Hip" , "ogit:Hip" ) ) ;
867+ assert ! ( schema. is_ancestor( "ogit:Leaf" , "ogit:Leaf" ) ) ;
868+ // Reflexivity must hold even for IRIs that aren't in the schema —
869+ // the relation is conceptually defined on the full IRI space.
870+ assert ! ( schema. is_ancestor( "ogit:Unknown" , "ogit:Unknown" ) ) ;
871+ }
872+
873+ #[ test]
874+ fn is_ancestor_direct_parent ( ) {
875+ let schema = build_chain_schema ( ) ;
876+ assert ! ( schema. is_ancestor( "ogit:Heel" , "ogit:Hip" ) ) ;
877+ assert ! ( schema. is_ancestor( "ogit:Hip" , "ogit:Twig" ) ) ;
878+ assert ! ( schema. is_ancestor( "ogit:Twig" , "ogit:Leaf" ) ) ;
879+ }
880+
881+ #[ test]
882+ fn is_ancestor_transitive_through_chain ( ) {
883+ let schema = build_chain_schema ( ) ;
884+ // Heel ← Hip ← Twig ← Leaf — transitivity at every depth.
885+ assert ! ( schema. is_ancestor( "ogit:Heel" , "ogit:Twig" ) ) ;
886+ assert ! ( schema. is_ancestor( "ogit:Heel" , "ogit:Leaf" ) ) ;
887+ assert ! ( schema. is_ancestor( "ogit:Hip" , "ogit:Leaf" ) ) ;
888+ }
889+
890+ #[ test]
891+ fn is_ancestor_antisymmetric ( ) {
892+ let schema = build_chain_schema ( ) ;
893+ // Reverse direction must NOT hold (except reflexive cases).
894+ assert ! ( !schema. is_ancestor( "ogit:Leaf" , "ogit:Heel" ) ) ;
895+ assert ! ( !schema. is_ancestor( "ogit:Twig" , "ogit:Hip" ) ) ;
896+ assert ! ( !schema. is_ancestor( "ogit:Hip" , "ogit:Heel" ) ) ;
897+ }
898+
899+ #[ test]
900+ fn is_ancestor_unknown_descendant_returns_false ( ) {
901+ let schema = build_chain_schema ( ) ;
902+ // Unknown descendant — no chain to walk. Only reflexive case
903+ // returns true (covered by `is_ancestor_reflexive`).
904+ assert ! ( !schema. is_ancestor( "ogit:Heel" , "ogit:Unknown" ) ) ;
905+ assert ! ( !schema. is_ancestor( "ogit:Hip" , "ogit:DefinitelyNotInSchema" ) ) ;
906+ }
907+
908+ #[ test]
909+ fn is_ancestor_unrelated_classes ( ) {
910+ // Two disjoint chains — Heel/Hip and OtherHeel/OtherHip.
911+ let src = "\
912+ @prefix ogit: <http://www.purl.org/ogit/> .\n \
913+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n \
914+ ogit:Heel a rdfs:Class .\n \
915+ ogit:Hip a rdfs:Class ; rdfs:subClassOf ogit:Heel .\n \
916+ ogit:OtherHeel a rdfs:Class .\n \
917+ ogit:OtherHip a rdfs:Class ; rdfs:subClassOf ogit:OtherHeel .";
918+ let triples = TurtleParser :: parse ( src) . unwrap ( ) ;
919+ let schema = OntologySchema :: from_triples ( & triples) . unwrap ( ) ;
920+ // Across disjoint chains: neither direction holds.
921+ assert ! ( !schema. is_ancestor( "ogit:Heel" , "ogit:OtherHip" ) ) ;
922+ assert ! ( !schema. is_ancestor( "ogit:OtherHeel" , "ogit:Hip" ) ) ;
923+ }
778924}
0 commit comments