diff --git a/src/hpc/dn_tree.rs b/src/hpc/dn_tree.rs index 1f472583..573153e9 100644 --- a/src/hpc/dn_tree.rs +++ b/src/hpc/dn_tree.rs @@ -105,7 +105,14 @@ fn is_zero(hv: &GraphHV) -> bool { /// For each bit position, the existing summary bit is kept with probability /// `1 - lr * boost`, and replaced by the input bit with probability `lr * boost`. /// This is implemented per-word using a stochastic mask. -fn bundle_into(current: &GraphHV, hv: &GraphHV, lr: f64, boost: f64, rng: &mut SplitMix64) -> GraphHV { +/// +/// # Visibility +/// +/// Exposed as `pub(crate)` so the Pillar-13 drift-check test in +/// `crate::hpc::pillar::hhtl_contraction` can run the production bundle +/// against its independently-derived Bernoulli-mixture reference. The +/// function is not part of the public API and may change without notice. +pub(crate) fn bundle_into(current: &GraphHV, hv: &GraphHV, lr: f64, boost: f64, rng: &mut SplitMix64) -> GraphHV { let effective_lr = (lr * boost).min(1.0); let mut result = current.clone(); diff --git a/src/hpc/ogit_bridge/schema.rs b/src/hpc/ogit_bridge/schema.rs index 59361939..945b19b4 100644 --- a/src/hpc/ogit_bridge/schema.rs +++ b/src/hpc/ogit_bridge/schema.rs @@ -586,6 +586,83 @@ impl OntologySchema { leaf_count, }) } + + /// Returns `true` iff `descendant` is reachable from `ancestor` by + /// walking the `rdfs:subClassOf` chain encoded in + /// [`EntityClass::parent`]. The relation is *reflexive*: every class + /// is its own ancestor (including unknown IRIs — which return `true` + /// only for the reflexive case `ancestor == descendant`). + /// + /// # Complexity + /// + /// O(depth) — walks at most `MAX_DEPTH = 64` parent links before + /// returning `false`, defensively guarding against any cycle that + /// might have slipped past the partial-order check upstream. Pillar-14 + /// asserts the closure is antisymmetric on synthetic DAGs; this + /// method's cycle guard is the runtime backstop for that assertion. + /// + /// # Use case + /// + /// The Pillar-14 drift-check test in `crate::hpc::pillar::ogit_lattice` + /// uses this method to verify the loaded ontology's closure satisfies + /// the same three partial-order axioms the synthetic-DAG probe + /// certifies. Beyond that, it is the natural query for any + /// type-gated cascade step: "may activation propagate from a tile of + /// type `descendant` to a tile of type `ancestor`?" + /// + /// # Example + /// + /// ```ignore + /// // Reflexive + /// assert!(schema.is_ancestor("ogit:Heel", "ogit:Heel")); + /// // Transitive via the subClassOf chain + /// assert!(schema.is_ancestor("ogit:Heel", "ogit:SomeLeaf")); + /// // No relation + /// assert!(!schema.is_ancestor("ogit:Leaf", "ogit:Heel")); + /// // Unknown descendant — only reflexive case returns true + /// assert!(!schema.is_ancestor("ogit:Heel", "ogit:Unknown")); + /// ``` + pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> bool { + // Reflexive case — handles both the "X is its own ancestor" + // identity and the unknown-class case (where neither IRI is in + // the schema but they are equal). + if ancestor == descendant { + return true; + } + + // Per the documented contract, unknown IRIs are an ancestor ONLY + // in the reflexive case. `from_triples` accepts `rdfs:subClassOf` + // targets without requiring them to be declared classes, so an + // `EntityClass.parent` can legally point at an IRI that's not in + // `self.entities`. Without this guard a malformed/partial schema + // could make `is_ancestor("missing:Class", "ogit:Leaf")` succeed + // (codex P2 on PR #189). + if !self.entities.contains_key(ancestor) { + return false; + } + + // Walk the parent chain from descendant upward, looking for ancestor. + // Defensive depth cap — see method docstring. + const MAX_DEPTH: usize = 64; + let mut current: &str = descendant; + for _ in 0..MAX_DEPTH { + let entity = match self.entities.get(current) { + Some(e) => e, + None => return false, // descendant unknown — no chain to walk + }; + let parent = match entity.parent.as_deref() { + Some(p) => p, + None => return false, // reached root without finding ancestor + }; + if parent == ancestor { + return true; + } + current = parent; + } + // Exceeded depth cap — treat as not-an-ancestor (defensive; this + // path should be unreachable on a well-formed schema). + false + } } // --------------------------------------------------------------------------- @@ -775,4 +852,112 @@ mod tests { // Both bits must be set assert!(family.bitmap.iter().all(|&b| b), "all leaf bits must be set"); } + + // ----------------------------------------------------------------------- + // is_ancestor — partial-order axiom queries on the loaded closure + // ----------------------------------------------------------------------- + + fn build_chain_schema() -> OntologySchema { + // Build a four-tier chain: Heel ← Hip ← Twig ← Leaf. + let src = "\ + @prefix ogit: .\n\ + @prefix rdfs: .\n\ + ogit:Heel a rdfs:Class .\n\ + ogit:Hip a rdfs:Class ; rdfs:subClassOf ogit:Heel .\n\ + ogit:Twig a rdfs:Class ; rdfs:subClassOf ogit:Hip .\n\ + ogit:Leaf a rdfs:Class ; rdfs:subClassOf ogit:Twig ."; + let triples = TurtleParser::parse(src).unwrap(); + OntologySchema::from_triples(&triples).unwrap() + } + + #[test] + fn is_ancestor_reflexive() { + let schema = build_chain_schema(); + assert!(schema.is_ancestor("ogit:Heel", "ogit:Heel")); + assert!(schema.is_ancestor("ogit:Hip", "ogit:Hip")); + assert!(schema.is_ancestor("ogit:Leaf", "ogit:Leaf")); + // Reflexivity must hold even for IRIs that aren't in the schema — + // the relation is conceptually defined on the full IRI space. + assert!(schema.is_ancestor("ogit:Unknown", "ogit:Unknown")); + } + + #[test] + fn is_ancestor_direct_parent() { + let schema = build_chain_schema(); + assert!(schema.is_ancestor("ogit:Heel", "ogit:Hip")); + assert!(schema.is_ancestor("ogit:Hip", "ogit:Twig")); + assert!(schema.is_ancestor("ogit:Twig", "ogit:Leaf")); + } + + #[test] + fn is_ancestor_transitive_through_chain() { + let schema = build_chain_schema(); + // Heel ← Hip ← Twig ← Leaf — transitivity at every depth. + assert!(schema.is_ancestor("ogit:Heel", "ogit:Twig")); + assert!(schema.is_ancestor("ogit:Heel", "ogit:Leaf")); + assert!(schema.is_ancestor("ogit:Hip", "ogit:Leaf")); + } + + #[test] + fn is_ancestor_antisymmetric() { + let schema = build_chain_schema(); + // Reverse direction must NOT hold (except reflexive cases). + assert!(!schema.is_ancestor("ogit:Leaf", "ogit:Heel")); + assert!(!schema.is_ancestor("ogit:Twig", "ogit:Hip")); + assert!(!schema.is_ancestor("ogit:Hip", "ogit:Heel")); + } + + #[test] + fn is_ancestor_unknown_descendant_returns_false() { + let schema = build_chain_schema(); + // Unknown descendant — no chain to walk. Only reflexive case + // returns true (covered by `is_ancestor_reflexive`). + assert!(!schema.is_ancestor("ogit:Heel", "ogit:Unknown")); + assert!(!schema.is_ancestor("ogit:Hip", "ogit:DefinitelyNotInSchema")); + } + + /// Codex P2 regression on PR #189: a class whose `rdfs:subClassOf` + /// edge points at an IRI not declared as a class must NOT satisfy + /// `is_ancestor(unknown_parent, child)`. Only the reflexive case + /// `is_ancestor(unknown, unknown)` should return true. + #[test] + fn is_ancestor_unknown_ancestor_returns_false_when_non_reflexive() { + // ogit:Leaf claims subClassOf ogit:Phantom, but ogit:Phantom is + // never `a rdfs:Class` — TurtleParser/from_triples accepts this + // (rdfs allows undeclared subClassOf targets). + let src = "\ + @prefix ogit: .\n\ + @prefix rdfs: .\n\ + ogit:Leaf a rdfs:Class ; rdfs:subClassOf ogit:Phantom ."; + let triples = TurtleParser::parse(src).unwrap(); + let schema = OntologySchema::from_triples(&triples).unwrap(); + + // The reflexive case still holds — defined on the full IRI space. + assert!(schema.is_ancestor("ogit:Phantom", "ogit:Phantom")); + + // But non-reflexive lookups against the undeclared ancestor MUST + // return false, regardless of any entity's parent field pointing + // at it. + assert!( + !schema.is_ancestor("ogit:Phantom", "ogit:Leaf"), + "is_ancestor must reject ancestors not declared as rdfs:Class" + ); + } + + #[test] + fn is_ancestor_unrelated_classes() { + // Two disjoint chains — Heel/Hip and OtherHeel/OtherHip. + let src = "\ + @prefix ogit: .\n\ + @prefix rdfs: .\n\ + ogit:Heel a rdfs:Class .\n\ + ogit:Hip a rdfs:Class ; rdfs:subClassOf ogit:Heel .\n\ + ogit:OtherHeel a rdfs:Class .\n\ + ogit:OtherHip a rdfs:Class ; rdfs:subClassOf ogit:OtherHeel ."; + let triples = TurtleParser::parse(src).unwrap(); + let schema = OntologySchema::from_triples(&triples).unwrap(); + // Across disjoint chains: neither direction holds. + assert!(!schema.is_ancestor("ogit:Heel", "ogit:OtherHip")); + assert!(!schema.is_ancestor("ogit:OtherHeel", "ogit:Hip")); + } }