Skip to content

Commit 730efbd

Browse files
committed
fix(ogit_bridge): reject unknown ancestors in is_ancestor (codex P2)
Codex flagged on PR #189 that `is_ancestor` returned true as soon as `entity.parent == ancestor`, even when `ancestor` was not a declared rdfs:Class in `self.entities`. Because `from_triples` accepts `rdfs:subClassOf` targets without requiring them to be declared, a partial or malformed ontology could make `is_ancestor("missing:Class", "ogit:Leaf")` succeed for a non-reflexive unknown ancestor — directly contradicting the method's documented contract that unknown IRIs are only true in the reflexive case. Fix: after the reflexive `ancestor == descendant` shortcut, early-return false if `ancestor` is not present in `self.entities`. Reflexivity on unknown IRIs is preserved (still defined on the full IRI space), but no entity's parent field can now project a phantom IRI into the ancestor closure. Added regression test `is_ancestor_unknown_ancestor_returns_false_when_non_reflexive` that parses a turtle source where `ogit:Leaf rdfs:subClassOf ogit:Phantom` but `ogit:Phantom` is never declared as a class. Asserts both: - `is_ancestor("ogit:Phantom", "ogit:Phantom")` → true (reflexive) - `is_ancestor("ogit:Phantom", "ogit:Leaf")` → false (was previously true — the bug) All 7 `is_ancestor` tests pass; lib clippy + fmt clean.
1 parent 6c4607d commit 730efbd

1 file changed

Lines changed: 39 additions & 0 deletions

File tree

src/hpc/ogit_bridge/schema.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,17 @@ impl OntologySchema {
630630
return true;
631631
}
632632

633+
// Per the documented contract, unknown IRIs are an ancestor ONLY
634+
// in the reflexive case. `from_triples` accepts `rdfs:subClassOf`
635+
// targets without requiring them to be declared classes, so an
636+
// `EntityClass.parent` can legally point at an IRI that's not in
637+
// `self.entities`. Without this guard a malformed/partial schema
638+
// could make `is_ancestor("missing:Class", "ogit:Leaf")` succeed
639+
// (codex P2 on PR #189).
640+
if !self.entities.contains_key(ancestor) {
641+
return false;
642+
}
643+
633644
// Walk the parent chain from descendant upward, looking for ancestor.
634645
// Defensive depth cap — see method docstring.
635646
const MAX_DEPTH: usize = 64;
@@ -905,6 +916,34 @@ mod tests {
905916
assert!(!schema.is_ancestor("ogit:Hip", "ogit:DefinitelyNotInSchema"));
906917
}
907918

919+
/// Codex P2 regression on PR #189: a class whose `rdfs:subClassOf`
920+
/// edge points at an IRI not declared as a class must NOT satisfy
921+
/// `is_ancestor(unknown_parent, child)`. Only the reflexive case
922+
/// `is_ancestor(unknown, unknown)` should return true.
923+
#[test]
924+
fn is_ancestor_unknown_ancestor_returns_false_when_non_reflexive() {
925+
// ogit:Leaf claims subClassOf ogit:Phantom, but ogit:Phantom is
926+
// never `a rdfs:Class` — TurtleParser/from_triples accepts this
927+
// (rdfs allows undeclared subClassOf targets).
928+
let src = "\
929+
@prefix ogit: <http://www.purl.org/ogit/> .\n\
930+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
931+
ogit:Leaf a rdfs:Class ; rdfs:subClassOf ogit:Phantom .";
932+
let triples = TurtleParser::parse(src).unwrap();
933+
let schema = OntologySchema::from_triples(&triples).unwrap();
934+
935+
// The reflexive case still holds — defined on the full IRI space.
936+
assert!(schema.is_ancestor("ogit:Phantom", "ogit:Phantom"));
937+
938+
// But non-reflexive lookups against the undeclared ancestor MUST
939+
// return false, regardless of any entity's parent field pointing
940+
// at it.
941+
assert!(
942+
!schema.is_ancestor("ogit:Phantom", "ogit:Leaf"),
943+
"is_ancestor must reject ancestors not declared as rdfs:Class"
944+
);
945+
}
946+
908947
#[test]
909948
fn is_ancestor_unrelated_classes() {
910949
// Two disjoint chains — Heel/Hip and OtherHeel/OtherHip.

0 commit comments

Comments
 (0)