diff --git a/Cargo.toml b/Cargo.toml index 5640bfd..090e079 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/ogar-render-askama", "crates/ogar-fma-skeleton", "crates/ogar-fma", + "crates/ogar-obo", "crates/ogar-cpic", "crates/ogar-adapter-python", "crates/ogar-adapter-csharp", diff --git a/crates/ogar-obo/.gitignore b/crates/ogar-obo/.gitignore new file mode 100644 index 0000000..979b00c --- /dev/null +++ b/crates/ogar-obo/.gitignore @@ -0,0 +1,3 @@ +obo-core.soa +*.soa +/tmp/ diff --git a/crates/ogar-obo/Cargo.toml b/crates/ogar-obo/Cargo.toml new file mode 100644 index 0000000..2414cf8 --- /dev/null +++ b/crates/ogar-obo/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ogar-obo" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "OBO-core reference bake — MONDO (disease) · HPO (phenotype) · Uberon (anatomy) · PATO (quality) · RO (relations) parsed to a canon 512-byte SoA NodeRow buffer (key|edges|value LE) the lance-graph loader reads zero-copy, plus the OWL-EL completion subset (is_a saturation + transitive-role existential propagation) the OBO EL profile actually exercises. Pure public CC-BY reference; never PHI." + +[features] +default = [] +serde = ["dep:serde"] + +[dependencies] +ogar-vocab = { path = "../ogar-vocab" } +serde = { workspace = true, optional = true } + +[dev-dependencies] diff --git a/crates/ogar-obo/README.md b/crates/ogar-obo/README.md new file mode 100644 index 0000000..ca47b39 --- /dev/null +++ b/crates/ogar-obo/README.md @@ -0,0 +1,59 @@ +# ogar-obo — the OBO-core reference bake + +Parses the OBO Foundry biological-ontology core into a **canonical 512-byte +SoA `NodeRow` buffer** the lance-graph loader reads **zero-copy**, plus the +OWL-EL completion subset the OBO EL profile actually exercises. + +| namespace | role | concept id | domain | +|---|---|---|---| +| MONDO | disease | `0x0B01` | OBO clinical-reference | +| HPO | phenotype / symptom | `0x0B02` | OBO clinical-reference | +| Uberon | anatomy spine | `0x0A02` | Anatomy (public, sibling of FMA `0x0A01`) | +| PATO | quality | `0x0B03` | OBO clinical-reference | +| RO | relations | `0x0B04` | OBO clinical-reference | + +Pure **public CC-BY / CC0** reference. **Zero PHI, zero consumer-private +codebook.** Wired exactly as `ogar-fma` / `ogar-cpic`. + +## The loader connection is a byte-layout contract + +`ogar-obo` emits the exact little-endian 512-byte geometry of +`lance_graph_contract::canonical_node::NodeRow` — `key(16) | edges(16) | +value(480)`. lance-graph's `node_rows_from_le_bytes` reads those bytes back as +`&[NodeRow]` with no deserialize. The CURIE numeric id (`MONDO:0007739` → +`7739`) is the node's 24-bit **identity**; the namespace is the **classid** +(canon-high `concept<<16 | app`). Compatibility is proven by the round-trip +test (`as_le_bytes` ↔ `rows_from_le_bytes`, 512×N, 64-align gate) — no +cross-repo compile coupling. + +## What survives the parse (never truncated) + +- **is_a / part_of** → the backbone rails + the EL subsumption/mereology spine. +- **has_phenotype / disease_has_location** → MONDO ↔ HP/Uberon convergence. +- **HP logical defs** (`hp-base.owl`) → HP `anatomy:quality` grounding + (`has_anatomy` → Uberon, `has_quality` → PATO). +- **xrefs** (MeSH · UMLS · OMIM · Orphanet · SNOMED · ICD) → the projection-join + / multilateration bearings **and** the guideline-spider path: a MeSH bearing + resolves a disease/phenotype to its clinical guideline (online→local). These + are load-bearing; the bake keeps every one. + +## Build & run + +``` +cargo test -p ogar-obo # 8 tests incl. the loader round-trip +cargo run -p ogar-obo --example bake_obo -- # holds the 6 pinned sources +``` + +## Release layout + +- **code + `manifest.json`** (source pins: PURL · version · sha256 · SPDX) → git. +- **`obo-core.soa`** (the 512×N derived artifact) → **release asset**, gitignored. +- OBO mandates open licenses, so the frozen source snapshots are re-hostable + alongside the derived bake for full replicability (re-fetch → re-bake → sha). + +## EL saturation (ELK subset, `reason.rs`) + +`is_a` transitivity · transitive-role `part_of` · existential-through-spine +(R∃): an HP grounded to a Uberon site is grounded to that site's ancestors too +— the deductive form of "inherit the grounding up". Full OWL-EL classification +(disjointness / unsatisfiability) is the follow-up pass. diff --git a/crates/ogar-obo/examples/bake_obo.rs b/crates/ogar-obo/examples/bake_obo.rs new file mode 100644 index 0000000..1411c7f --- /dev/null +++ b/crates/ogar-obo/examples/bake_obo.rs @@ -0,0 +1,101 @@ +//! Full OBO-core bake driver — reads the five `.obo` sources + `hp-base.owl` +//! from a directory, bakes the 512-byte SoA + SPO triples + xref table, runs +//! the EL-completion saturation, verifies the packet round-trips through the +//! lance-graph loader contract, and writes the `.soa` artifact. +//! +//! Usage: `cargo run -p ogar-obo --example bake_obo -- [out.soa]` +//! (`` holds `mondo.obo hp.obo uberon.obo pato.obo ro.obo +//! hp-base.owl`). The sources are pinned in the release manifest, fetched to a +//! scratch dir, never committed. + +use ogar_obo::{ + Namespace, bake, merge_logical_defs, parse_hp_logical_defs, parse_obo, reason, + rows_from_le_bytes, as_le_bytes, +}; +use std::collections::HashMap; + +fn main() { + let mut args = std::env::args().skip(1); + let dir = args.next().unwrap_or_else(|| "/tmp/obo".to_string()); + let out = args.next().unwrap_or_else(|| format!("{dir}/obo-core.soa")); + + let mut nodes = HashMap::new(); + for f in ["mondo", "hp", "uberon", "pato", "ro"] { + let path = format!("{dir}/{f}.obo"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {path}: {e}")); + let part = parse_obo(&text); + let (terms, edges) = ( + part.len(), + part.values().map(|n| n.is_a.len() + n.rel.len() + n.xref.len()).sum::(), + ); + println!(" parsed {f:8} terms={terms:6} edges+xref={edges}"); + for (id, n) in part { + let e = nodes.entry(id).or_insert_with(ogar_obo::OboNode::default); + e.is_a.extend(n.is_a); + e.rel.extend(n.rel); + e.xref.extend(n.xref); + e.obsolete |= n.obsolete; + } + } + + // HPO logical-definition grounding (anatomy:quality) from hp-base.owl. + let owl = std::fs::read_to_string(format!("{dir}/hp-base.owl")).unwrap_or_default(); + let defs = parse_hp_logical_defs(&owl); + println!(" hp-base logical defs: {} HP->Uberon/PATO grounding edges", defs.len()); + merge_logical_defs(&mut nodes, &defs); + + let baked = bake(&nodes, 0x0000); + let s = &baked.stats; + println!("\n=== BAKE ==="); + println!(" live core nodes (rows) : {}", s.nodes); + println!(" obsolete (not tiled) : {}", s.obsolete); + println!(" SPO triples : {}", s.triples); + println!(" is_a cycles : {}", s.is_a_cycles); + println!(" dangling core targets : {}", s.dangling); + println!(" MONDO->HP resolve : {}", s.mondo_hp); + println!(" HP->UBERON resolve : {}", s.hp_uberon); + println!(" HP->PATO resolve : {}", s.hp_pato); + println!(" xrefs preserved : {} (MeSH bearings: {})", s.xrefs, s.mesh_xrefs); + + // Per-namespace row census. + let mut census: HashMap = HashMap::new(); + for id in &baked.ids { + *census.entry(id.ns).or_default() += 1; + } + for ns in [Namespace::Mondo, Namespace::Hpo, Namespace::Uberon, Namespace::Pato, Namespace::Ro] { + println!(" {:8}: {}", ns.prefix(), census.get(&(ns as u8)).copied().unwrap_or(0)); + } + + println!("\n=== EL SATURATION (ELK subset, excavated to Rust) ==="); + let el = reason::saturate(&baked.triples); + println!(" is_a subsumption pairs : {}", el.subsumption_pairs); + println!(" part_of transitive pairs : {}", el.part_of_pairs); + println!(" existential inferred (R∃): {} (grounding inherited up the spine)", el.existential_inferred); + println!(" unsatisfiable : {} (no disjointness axioms in base obo)", el.unsatisfiable); + + // Write the artifact + verify it round-trips through the loader contract. + let bytes = as_le_bytes(&baked.rows); + std::fs::write(&out, bytes).unwrap_or_else(|e| panic!("write {out}: {e}")); + println!("\n=== ARTIFACT ==="); + println!(" {out} ({} bytes = {} rows × 512)", bytes.len(), baked.rows.len()); + let readback = std::fs::read(&out).expect("reread"); + match rows_from_le_bytes(&readback) { + Some(rows) => { + // NB: a fresh Vec from fs::read may not be 64-aligned; the loader + // returns None then and a real consumer uses FixedSizeBinary(512) + // (arrow-aligned). We verify the in-memory aligned view instead. + println!(" reread rows_from_le_bytes: {} rows (aligned buffer)", rows.len()); + } + None => { + let inmem = rows_from_le_bytes(bytes).expect("in-memory aligned view"); + assert_eq!(inmem.len(), baked.rows.len()); + println!( + " reread buffer not 64-aligned (fs::read) — in-memory aligned view OK: {} rows \ + (Lance FixedSizeBinary(512) is arrow-aligned on the real path)", + inmem.len() + ); + } + } + println!(" loader contract: VERIFIED (as_le_bytes ↔ rows_from_le_bytes, 512×N, 64-align gate)"); +} diff --git a/crates/ogar-obo/manifest.json b/crates/ogar-obo/manifest.json new file mode 100644 index 0000000..953c37b --- /dev/null +++ b/crates/ogar-obo/manifest.json @@ -0,0 +1,93 @@ +{ + "bake": "ogar-obo / obo-core.soa", + "pass": "1 (structural) + EL saturation", + "layout": "canon 512-byte NodeRow (key|edges|value LE); loader = lance_graph_contract::canonical_node::node_rows_from_le_bytes", + "row_stride": 512, + "sources": [ + { + "name": "mondo", + "file": "mondo.obo", + "version": "releases/2026-07-06", + "spdx": "CC-BY-4.0", + "upstream": "http://purl.obolibrary.org/obo/mondo.obo", + "redistribute": true, + "sha256": "75c51066741e04c0ec4751210911d29e17e16431ed2ba5b8a46fc6a37ff5b00e", + "bytes": 52967808 + }, + { + "name": "hp", + "file": "hp.obo", + "version": "hp/releases/2026-06-23", + "spdx": "HPO-custom-open", + "upstream": "http://purl.obolibrary.org/obo/hp.obo", + "redistribute": true, + "sha256": "a5092cbdf605f568403cf7380d9173014015692433b2cc631bc5c1b053876b1b", + "bytes": 11222341 + }, + { + "name": "uberon", + "file": "uberon.obo", + "version": "releases/2026-06-19", + "spdx": "CC-BY-3.0", + "upstream": "http://purl.obolibrary.org/obo/uberon.obo", + "redistribute": true, + "sha256": "7f06d8e8442008a67132a1599b652e86fe0c52d75c8d6bc5b0cc36a0031e6b3f", + "bytes": 22414082 + }, + { + "name": "pato", + "file": "pato.obo", + "version": "releases/2025-05-14", + "spdx": "CC-BY-4.0", + "upstream": "http://purl.obolibrary.org/obo/pato.obo", + "redistribute": true, + "sha256": "9b65efdf7d8d96bafd54637041cc615404ac2c88608efbcf54efa0a369bb1f75", + "bytes": 725388 + }, + { + "name": "ro", + "file": "ro.obo", + "version": "releases/2025-12-17", + "spdx": "CC-BY-4.0", + "upstream": "http://purl.obolibrary.org/obo/ro.obo", + "redistribute": true, + "sha256": "e34a2ea60fc15114edd4494c912ac0558ae51d31a1e5d62c6c0db0dac9e90449", + "bytes": 454026 + }, + { + "name": "hp-base", + "file": "hp-base.owl", + "version": "hp/releases/2026-06-23", + "spdx": "HPO-custom-open", + "upstream": "http://purl.obolibrary.org/obo/hp/hp-base.owl", + "redistribute": true, + "sha256": "8aab8c4d7f11189def5d4e64c17190dfc90a495fe9f49a451ea2caefc174e153", + "bytes": 49479533 + } + ], + "artifact": { + "file": "obo-core.soa", + "sha256": "16fb28cbea9bcf26e429684750e49e7606b055662f31172e475c5e2c6b9e9f91", + "bytes": 35224064, + "rows": 68797 + }, + "validation": { + "is_a_cycles": 0, + "dangling": 0, + "fidelity": "lossless parse", + "backbone": { + "MONDO->HP": 1299, + "HP->UBERON": 6514, + "HP->PATO": 12486 + }, + "xrefs_preserved": 212345, + "mesh_bearings": 9200, + "el_saturation": { + "is_a_subsumption_pairs": 738651, + "part_of_pairs": 1634977, + "existential_inferred": 9437429, + "unsatisfiable": 0 + } + }, + "note": "Public CC-BY OBO reference. Sources re-hostable frozen; the .soa is the derived release asset (gitignored). Zero PHI, zero consumer-private reference. classids: 0x03 Ontology domain (mondo 0x0301 / hpo 0x0302 / uberon 0x0303 / pato 0x0304 / ro 0x0305)." +} \ No newline at end of file diff --git a/crates/ogar-obo/src/lib.rs b/crates/ogar-obo/src/lib.rs new file mode 100644 index 0000000..cb36b8c --- /dev/null +++ b/crates/ogar-obo/src/lib.rs @@ -0,0 +1,808 @@ +//! # ogar-obo — the OBO-core reference bake +//! +//! Parses the OBO Foundry biological-ontology core — **MONDO** (disease), +//! **HPO** (phenotype/symptom), **Uberon** (anatomy), **PATO** (quality), +//! **RO** (relations) — into a canonical **512-byte SoA `NodeRow` buffer** +//! that the lance-graph loader reads **zero-copy**, plus the OWL-EL +//! completion subset the OBO EL profile actually exercises (see [`reason`]). +//! +//! ## Why here (and why zero PHI) +//! +//! OBO is public CC-BY / CC0 reference vocabulary — the biological *backbone* +//! every clinical consumer attaches to. It lives in OGAR (the public +//! producer / codebook) next to [`ogar_vocab`], `ogar-fma`, `ogar-cpic`, and +//! carries **no patient data and no consumer-private codebook** — a consumer +//! wires it exactly as it wires `ogar-fma`. The Anatomy-vs-Health firewall +//! ([`ogar_vocab`] `canonical_concept` docs) holds: the OBO reference lives in +//! the public `0x03` Ontology domain, never the `0x09` Health PHI domain. +//! +//! ## The loader connection is a BYTE-LAYOUT contract, not a code dep +//! +//! This crate stays lean (deps: `ogar-vocab` only). It emits the exact +//! little-endian 512-byte `NodeRow` geometry +//! (`lance_graph_contract::canonical_node`): `key(16) | edges(16) | +//! value(480)`. lance-graph's `node_rows_from_le_bytes` reads those bytes +//! back as `&[NodeRow]` with no deserialize — so byte compatibility, verified +//! by fixture ([`tests`] + a lance-graph-side load test), IS the connection. +//! No cross-repo compile coupling. +//! +//! ```text +//! key (16, LE) edges (16) value (480) +//! ┌────────┬────┬────┬────┬──────┬──────┐ ┌──────────────┐ ┌─────────────┐ +//! classid HEEL HIP TWIG family identity 12 in + 4 out tenant slab +//! u32(4) u16 u16 u16 u24(3) u24(3) 1 byte per slot (EntityType…) +//! └────────┴────┴────┴────┴──────┴──────┘ └──────────────┘ └─────────────┘ +//! ``` +//! +//! The CURIE numeric id (`MONDO:0007739` → `7739`) is the node's 24-bit +//! **identity**; the namespace is the **classid** (canon-high `concept<<16 | +//! app`). All OBO numeric ids fit in 24 bits (< 16.7 M). + +#![deny(missing_docs)] + +pub mod reason; + +/// Row stride of the canonical SoA node — `key(16) + edges(16) + value(480)`. +/// Mirrors `lance_graph_contract::canonical_node::NODE_ROW_STRIDE`. +pub const NODE_ROW_STRIDE: usize = 512; +/// Byte offset of the edge block within a row. +pub const EDGES_OFFSET: usize = 16; +/// Byte offset of the value slab within a row. +pub const VALUE_OFFSET: usize = 32; +/// `EntityType` value-tenant offset **within the 480-byte value slab** +/// (tenant ordinal 8; see the contract `VALUE_TENANTS` carve). A `u16` +/// that records this node's OBO namespace so a reader can group by ontology +/// without decoding the classid. +pub const ENTITY_TYPE_SLAB_OFFSET: usize = 96; + +/// The five OBO-core namespaces this bake carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Namespace { + /// MONDO — disease (`0x0301`, the `0x03` OBO Ontology domain). + Mondo, + /// HPO — human phenotype / clinical symptom (`0x0302`). + Hpo, + /// Uberon — anatomy spine (`0x0303`). Cross-references FMA (`ogar-fma`, + /// `0x0A` Anatomy) by edge, not by shared domain byte. + Uberon, + /// PATO — phenotypic quality (`0x0304`). + Pato, + /// RO — relations ontology (predicate classes; `0x0305`). Predicates ride + /// the [`Predicate`] byte palette on edges, not as node classids, but RO + /// term nodes are still baked for completeness. + Ro, +} + +impl Namespace { + /// The OBO CURIE prefix (`"MONDO"`, `"HP"`, `"UBERON"`, `"PATO"`, `"RO"`). + #[must_use] + pub const fn prefix(self) -> &'static str { + match self { + Namespace::Mondo => "MONDO", + Namespace::Hpo => "HP", + Namespace::Uberon => "UBERON", + Namespace::Pato => "PATO", + Namespace::Ro => "RO", + } + } + + /// Parse an OBO CURIE prefix to a namespace. + #[must_use] + pub fn from_prefix(p: &str) -> Option { + Some(match p { + "MONDO" => Namespace::Mondo, + "HP" => Namespace::Hpo, + "UBERON" => Namespace::Uberon, + "PATO" => Namespace::Pato, + "RO" => Namespace::Ro, + _ => return None, + }) + } + + /// The canonical hi-u16 **concept id** (`0xDDCC`, domain `DD` · slot `CC`) + /// this namespace routes on — the public-reference assignment. All five + /// OBO namespaces live in the `0x03` **Ontology** domain (public reference, + /// firewall-separated from `0x09` Health PHI). The domain is reserved in + /// [`ogar_vocab`] as `ConceptDomain::Ontology` with **zero shared CODEBOOK + /// rows** — these concept ids are authoritative here, kept plug-and-play so + /// only consumers that dep `ogar-obo` compile them (ERP / project consumers + /// never pull them in). + #[must_use] + pub const fn concept_id(self) -> u16 { + match self { + Namespace::Mondo => 0x0301, + Namespace::Hpo => 0x0302, + Namespace::Uberon => 0x0303, + Namespace::Pato => 0x0304, + Namespace::Ro => 0x0305, + } + } + + /// The full V3 render classid under a consumer's app prefix — canon-high + /// `(concept as u32) << 16 | app_prefix`. Identical idiom to + /// `ogar_fma::FmaStructure::render_classid` and `ogar_vocab::render_classid`. + #[must_use] + pub const fn render_classid(self, app_prefix: u16) -> u32 { + ((self.concept_id() as u32) << 16) | (app_prefix as u32) + } +} + +/// The RO-predicate byte palette carried on edges (the `P` of an SPO triple). +/// One byte indexes the relationship type; the small core set the OBO EL +/// profile uses. `0` is reserved (unset / fall-through). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(u8)] +pub enum Predicate { + /// `is_a` / `rdfs:subClassOf` — the subsumption spine (transitive). + IsA = 1, + /// `BFO:0000050` part_of — mereology (transitive; the anatomy backbone). + PartOf = 2, + /// `RO:0002200` has_phenotype — disease → HPO manifestation. + HasPhenotype = 3, + /// `RO:0004026` disease_has_location — disease → Uberon site. + HasLocation = 4, + /// existential toward an anatomy genus (`has_part`/`inheres_in` → Uberon) + /// harvested from an HPO logical definition (`intersection_of`). + HasAnatomy = 5, + /// existential toward a PATO quality harvested from a logical definition. + HasQuality = 6, + /// any other `relationship:` predicate, preserved generically. + Other = 7, +} + +/// A parsed OBO term id: `(namespace, 24-bit numeric)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TermId { + /// Ordinal namespace tag (see [`Namespace`]). + pub ns: u8, + /// The CURIE numeric id (fits 24 bits for every OBO id). + pub num: u32, +} + +impl TermId { + /// Parse a CURIE like `"MONDO:0007739"` into a [`TermId`], or `None` if the + /// prefix is outside the OBO core or the numeric part is malformed / >24-bit. + #[must_use] + pub fn parse(curie: &str) -> Option { + let (p, n) = curie.split_once(':')?; + let ns = Namespace::from_prefix(p)?; + let num: u32 = n.parse().ok()?; + if num > 0x00FF_FFFF { + return None; + } + Some(TermId { + ns: ns as u8, + num, + }) + } + + /// This term's namespace. + #[must_use] + pub fn namespace(self) -> Namespace { + // ns is always constructed from a Namespace discriminant. + [ + Namespace::Mondo, + Namespace::Hpo, + Namespace::Uberon, + Namespace::Pato, + Namespace::Ro, + ][self.ns as usize] + } +} + +/// An external cross-reference — the `xref:` lines OBO carries to the +/// clinical/coding world (MeSH, UMLS, OMIM, Orphanet, SNOMED, ICD, …). +/// +/// **These are load-bearing, never truncated.** They are (a) the horizontal +/// **projection-join / multilateration bearings** — the "who am I" evidence a +/// broadMatch is triangulated against — and (b) the **guideline-spider path**: +/// a MeSH reference resolves a disease/phenotype to its clinical guideline, +/// pulled online→local by a consumer, even when the end surface is "just" a +/// basic PDF viewer. Dropping xrefs during CURIE parsing would silently kill +/// both downstreams. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Xref { + /// which external coding system this reference points into + pub source: XrefSource, + /// the external id verbatim (e.g. `"D003922"` for `MeSH:D003922`) — kept as + /// a string because external coding systems are not the OBO 24-bit id scheme + pub id: String, +} + +/// External coding systems OBO xrefs point into — the projection lanes. MeSH is +/// first-class (the guideline-spider source); everything unrecognised is +/// preserved verbatim in [`XrefSource::Other`], never dropped. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum XrefSource { + /// MeSH (`MeSH` / `MSH` / `MESH`) — the guideline-spider bearing. + Mesh, + /// UMLS Metathesaurus CUI. + Umls, + /// OMIM (Mendelian). + Omim, + /// Orphanet (`Orphanet` / `ORPHA`). + Orphanet, + /// SNOMED CT (`SCTID` / `SNOMEDCT`). + Snomed, + /// ICD-10 / ICD-10-CM / ICD-9. + Icd, + /// any other coding system — the prefix preserved verbatim. + Other(String), +} + +impl XrefSource { + /// Map an xref CURIE prefix to a source (case-insensitive on the known set). + #[must_use] + pub fn from_prefix(p: &str) -> XrefSource { + match p.to_ascii_uppercase().as_str() { + "MESH" | "MSH" => XrefSource::Mesh, + "UMLS" => XrefSource::Umls, + "OMIM" | "OMIMPS" | "MIM" => XrefSource::Omim, + "ORPHANET" | "ORPHA" => XrefSource::Orphanet, + "SCTID" | "SNOMEDCT" | "SNOMEDCT_US" | "SNOMED" => XrefSource::Snomed, + "ICD10" | "ICD10CM" | "ICD-10" | "ICD9" | "ICD9CM" => XrefSource::Icd, + _ => XrefSource::Other(p.to_string()), + } + } +} + +/// One OBO node with its outgoing typed edges + preserved xrefs (the +/// intermediate the bake packs). +#[derive(Debug, Clone, Default)] +pub struct OboNode { + /// `is_a` parents. + pub is_a: Vec, + /// typed relationships `(predicate, object)`. + pub rel: Vec<(Predicate, TermId)>, + /// external cross-references — the projection-join / guideline-spider + /// bearings, preserved verbatim (never truncated). + pub xref: Vec, + /// whether the term is obsolete (kept for the read-only tail; not tiled). + pub obsolete: bool, +} + +/// A 64-byte-aligned 512-byte row cell — the alignment `node_rows_from_le_bytes` +/// requires for a genuine zero-copy `&[NodeRow]` view. Emitting into a +/// `Vec` (not a bare `Vec`) guarantees the buffer pointer is +/// 64-aligned, so lance-graph reads it in place. +#[derive(Clone, Copy)] +#[repr(C, align(64))] +pub struct Row512(pub [u8; NODE_ROW_STRIDE]); + +impl Row512 { + /// Zero row. + #[must_use] + pub const fn zeroed() -> Self { + Row512([0u8; NODE_ROW_STRIDE]) + } +} + +/// Build the 16-byte canon key: `classid(4) | HEEL(2) | HIP(2) | TWIG(2) | +/// family(3) | identity(3)`, all little-endian. HEEL/HIP/TWIG (HHTL cascade) +/// and family stay `0` in this first bake — the zero-fallback ladder means +/// `identity` alone discriminates until an HHTL basin mint wakes routing +/// (RESERVE, DON'T RECLAIM: fixed offsets, zero layout change later). +fn pack_key(classid: u32, identity: u32) -> [u8; 16] { + let mut k = [0u8; 16]; + k[0..4].copy_from_slice(&classid.to_le_bytes()); + // 4..10 HEEL/HIP/TWIG = 0 (dormant cascade) + // 10..13 family = 0 (dormant basin) + let id = identity.to_le_bytes(); // low 3 bytes are the 24-bit identity + k[13] = id[0]; + k[14] = id[1]; + k[15] = id[2]; + k +} + +/// Pack one node into its 512-byte row. Edges are placed **out-of-line** as +/// SPO triples (see [`bake`]); the in-row `edges` block records the *degree* +/// per predicate (a one-byte histogram, saturating at 255) so a reader has the +/// node's local shape without the triple table, and the `EntityType` value +/// tenant records the namespace. The one-byte basin-adjacency use of the edge +/// block is deferred to the HHTL-clustering pass (RESERVE, DON'T RECLAIM). +fn pack_row(classid: u32, id: &TermId, node: &OboNode) -> Row512 { + let mut row = Row512::zeroed(); + row.0[0..16].copy_from_slice(&pack_key(classid, id.num)); + // edges block [16,32): a per-predicate degree histogram (index by Predicate + // discriminant), saturating. Slot 0 reserved. + let mut deg = [0u16; 8]; + deg[Predicate::IsA as usize] = node.is_a.len() as u16; + for (p, _) in &node.rel { + deg[*p as usize] = deg[*p as usize].saturating_add(1); + } + for (i, d) in deg.iter().enumerate() { + row.0[EDGES_OFFSET + i] = (*d).min(255) as u8; + } + // value slab: EntityType tenant (u16 namespace) at its carve offset. + let et = VALUE_OFFSET + ENTITY_TYPE_SLAB_OFFSET; + row.0[et..et + 2].copy_from_slice(&(id.ns as u16).to_le_bytes()); + row +} + +// ── OBO `.obo` parser ───────────────────────────────────────────────────── + +/// An SPO edge: `subject --predicate--> object`, the out-of-line triple form +/// lance-graph's SPO store consumes natively. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Triple { + /// subject term + pub s: TermId, + /// predicate (RO palette) + pub p: Predicate, + /// object term + pub o: TermId, +} + +/// Classify a raw `(subject_ns, object)` edge into an RO [`Predicate`] by the +/// namespace pair — the robust, measurement-matched mapping (§backbone +/// convergence): the predicate is implied by which two ontologies an edge +/// joins, so we never depend on the exact `relationship:` label string. +fn classify(subj: Namespace, obj: Namespace) -> Predicate { + use Namespace::{Hpo, Mondo, Pato, Uberon}; + match (subj, obj) { + (Mondo, Hpo) => Predicate::HasPhenotype, + (Mondo, Uberon) => Predicate::HasLocation, + (Hpo, Uberon) => Predicate::HasAnatomy, + (Hpo, Pato) => Predicate::HasQuality, + (Uberon, Uberon) => Predicate::PartOf, + _ => Predicate::Other, + } +} + +/// Parse one OBO `.obo` document's `[Term]` stanzas into `(id -> node)`. +/// Recognises `id:`, `is_obsolete:`, `is_a:`, `relationship:` and +/// `intersection_of:` (the logical-definition genus/differentia); every +/// CURIE outside the OBO core (imported CL / GO / CHEBI / BFO relations …) +/// is silently skipped — only the five core namespaces are tiled. +#[must_use] +pub fn parse_obo(text: &str) -> std::collections::HashMap { + use std::collections::HashMap; + let mut nodes: HashMap = HashMap::new(); + let mut cur: Option = None; + let mut in_term = false; + for line in text.lines() { + if line == "[Term]" { + in_term = true; + cur = None; + continue; + } + if line.starts_with('[') { + in_term = false; + cur = None; + continue; + } + if !in_term { + continue; + } + if let Some(rest) = line.strip_prefix("id: ") { + cur = TermId::parse(rest.trim()); + if let Some(id) = cur { + nodes.entry(id).or_default(); + } + } else if line.starts_with("is_obsolete: true") { + if let Some(id) = cur { + nodes.entry(id).or_default().obsolete = true; + } + } else if let Some(rest) = line.strip_prefix("is_a: ") { + if let (Some(sid), Some(t)) = (cur, TermId::parse(first_curie(rest))) { + nodes.entry(sid).or_default().is_a.push(t); + } + } else if let Some(rest) = line.strip_prefix("relationship: ") { + // `relationship: ! label` — the target is the LAST + // whitespace token before any `!`. + if let Some(sid) = cur + && let Some(t) = last_curie(rest) { + let p = classify(sid.namespace(), t.namespace()); + nodes.entry(sid).or_default().rel.push((p, t)); + } + } else if let Some(rest) = line.strip_prefix("intersection_of: ") { + if let Some(sid) = cur + && let Some(t) = last_curie(rest) { + let p = classify(sid.namespace(), t.namespace()); + nodes.entry(sid).or_default().rel.push((p, t)); + } + } else if let Some(rest) = line.strip_prefix("xref: ") { + // `xref: : ! label` — the projection-join / guideline + // bearing. Kept verbatim; NEVER truncated (MeSH → Leitlinie spider). + if let Some(sid) = cur { + let tok = rest.split('!').next().unwrap_or(rest).trim(); + let tok = tok.split_whitespace().next().unwrap_or(tok); + if let Some((src, id)) = tok.split_once(':') + && !src.is_empty() && !id.is_empty() { + nodes.entry(sid).or_default().xref.push(Xref { + source: XrefSource::from_prefix(src), + id: id.to_string(), + }); + } + } + } + } + nodes +} + +/// First whitespace token of a line (before any `!` comment) — the target of +/// an `is_a:` line. +fn first_curie(s: &str) -> &str { + s.split('!').next().unwrap_or(s).split_whitespace().next().unwrap_or("").trim() +} + +/// Last CURIE-shaped token before any `!` — the object of a `relationship:` / +/// `intersection_of:` line (the predicate is the earlier token). +fn last_curie(s: &str) -> Option { + let head = s.split('!').next().unwrap_or(s); + head.split_whitespace().rfind(|t| t.contains(':')).and_then(TermId::parse) +} + +// ── bake: nodes+edges → 512-byte rows + SPO triples + stats ──────────────── + +/// Validation invariants recomputed from the baked graph — the fidelity / +/// structural / convergence numbers the OBO-core validation pass asserts. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BakeStats { + /// live (non-obsolete) core nodes tiled into rows + pub nodes: usize, + /// obsolete terms (kept in the map, not tiled) + pub obsolete: usize, + /// total SPO edges emitted + pub triples: usize, + /// `is_a` cycles found (SCC size > 1) — must be 0 on a clean OBO core + pub is_a_cycles: usize, + /// edges whose object namespace is core but the object node is absent + pub dangling: usize, + /// MONDO→HP has_phenotype edges resolving to a loaded HP node + pub mondo_hp: usize, + /// HP→Uberon has_anatomy edges resolving (the logical-def grounding) + pub hp_uberon: usize, + /// HP→PATO has_quality edges resolving + pub hp_pato: usize, + /// total preserved xrefs (projection-join / guideline bearings) + pub xrefs: usize, + /// MeSH xrefs specifically — the guideline-spider bearings + pub mesh_xrefs: usize, +} + +/// The bake result: the 512-byte SoA rows (loader-facing) + the SPO triple +/// table (lance-graph SPO-store-facing) + the validation stats. +pub struct Bake { + /// One 64-aligned 512-byte row per live core node, sorted by `(ns, num)` + /// so the buffer is a deterministic, HHTL-ready ordering. + pub rows: Vec, + /// The node ids in row order (row `i` ↔ `ids[i]`). + pub ids: Vec, + /// SPO edges (out-of-line; the EdgeBlock byte model is the later HHTL pass). + pub triples: Vec, + /// preserved external cross-references — the projection-join / + /// guideline-spider table: `(subject term, xref)`. NEVER truncated; MeSH + /// entries are what a consumer resolves to a Leitlinie (online→local PDF). + pub xrefs: Vec<(TermId, Xref)>, + /// recomputed invariants + pub stats: BakeStats, +} + +/// Bake a merged `(id → node)` map (all five `.obo` unioned, plus any HPO +/// logical-def edges folded into the nodes' `rel`) into [`Bake`]. `app_prefix` +/// is the lo-u16 render skin (`0x0000` = the canonical reference skin). +#[must_use] +pub fn bake( + nodes: &std::collections::HashMap, + app_prefix: u16, +) -> Bake { + let mut ids: Vec = nodes + .iter() + .filter(|(_, n)| !n.obsolete) + .map(|(id, _)| *id) + .collect(); + ids.sort_unstable(); + + let mut rows = Vec::with_capacity(ids.len()); + let mut triples = Vec::new(); + let mut xrefs = Vec::new(); + let mut stats = BakeStats { + obsolete: nodes.values().filter(|n| n.obsolete).count(), + ..Default::default() + }; + + for id in &ids { + let node = &nodes[id]; + let classid = id.namespace().render_classid(app_prefix); + rows.push(pack_row(classid, id, node)); + // Preserve every external cross-reference — the projection-join / + // guideline-spider bearings. NEVER truncated. + for x in &node.xref { + if x.source == XrefSource::Mesh { + stats.mesh_xrefs += 1; + } + xrefs.push((*id, x.clone())); + } + for parent in &node.is_a { + triples.push(Triple { + s: *id, + p: Predicate::IsA, + o: *parent, + }); + if !nodes.contains_key(parent) { + stats.dangling += 1; + } + } + for (p, o) in &node.rel { + triples.push(Triple { + s: *id, + p: *p, + o: *o, + }); + if !nodes.contains_key(o) { + stats.dangling += 1; + } else { + match p { + Predicate::HasPhenotype => stats.mondo_hp += 1, + Predicate::HasAnatomy => stats.hp_uberon += 1, + Predicate::HasQuality => stats.hp_pato += 1, + _ => {} + } + } + } + } + stats.nodes = ids.len(); + stats.triples = triples.len(); + stats.xrefs = xrefs.len(); + stats.is_a_cycles = reason::count_is_a_cycles(&triples); + Bake { + rows, + ids, + triples, + xrefs, + stats, + } +} + +// ── the loader connection: 64-aligned LE bytes ↔ &[Row512] ───────────────── + +/// Zero-copy view of the row buffer as contiguous LE bytes — the packet a +/// Lance `FixedSizeBinary(512)` column stores, and exactly what +/// `lance_graph_contract::canonical_node::node_rows_from_le_bytes` reads back +/// as `&[NodeRow]`. Length is `rows.len() * 512`. +#[must_use] +pub fn as_le_bytes(rows: &[Row512]) -> &[u8] { + // SAFETY: Row512 is #[repr(C, align(64))] wrapping [u8; 512] — plain bytes, + // no padding, no niche. Viewing a &[Row512] as &[u8] of len*512 is the same + // column-store packing lance-graph's own NodeRowPacket::as_le_bytes does; + // every byte is init and valid for read, align(64) ⊇ align(1). + unsafe { core::slice::from_raw_parts(rows.as_ptr().cast::(), rows.len() * NODE_ROW_STRIDE) } +} + +/// The inverse — a zero-copy `&[Row512]` over an external LE buffer, mirroring +/// `node_rows_from_le_bytes`: `Some` iff the length is a whole number of +/// 512-byte rows AND the pointer is 64-aligned (so lance-graph's identical +/// check would also accept it — this is the cross-repo compatibility proof). +#[must_use] +pub fn rows_from_le_bytes(bytes: &[u8]) -> Option<&[Row512]> { + if bytes.is_empty() { + return Some(&[]); + } + if !bytes.len().is_multiple_of(NODE_ROW_STRIDE) { + return None; + } + if !(bytes.as_ptr() as usize).is_multiple_of(core::mem::align_of::()) { + return None; + } + let n = bytes.len() / NODE_ROW_STRIDE; + // SAFETY: length is an exact multiple of the stride and the pointer is + // align_of::()-aligned (both checked above); every 512-byte window + // is a valid Row512 (all-bytes type, no invalid bit pattern). + Some(unsafe { core::slice::from_raw_parts(bytes.as_ptr().cast::(), n) }) +} + +/// Decode a row's key back to its `(classid, identity)` — the reader side of +/// [`pack_key`], used by the round-trip proof. +#[must_use] +pub fn decode_key(row: &Row512) -> (u32, u32) { + let classid = u32::from_le_bytes([row.0[0], row.0[1], row.0[2], row.0[3]]); + let identity = u32::from_le_bytes([row.0[13], row.0[14], row.0[15], 0]); + (classid, identity) +} + +const _: () = assert!(core::mem::size_of::() == 512); +const _: () = assert!(core::mem::align_of::() == 64); + +// ── HPO logical definitions (hp-base.owl) — the anatomy:quality grounding ── + +/// Parse HPO's OWL logical definitions out of `hp-base.owl` (RDF/XML): the +/// `owl:equivalentClass` blocks that decompose a phenotype as +/// `has_part some (UBERON … and has_quality some PATO …)`. The plain `hp.obo` +/// release strips these, so HP→Uberon/PATO grounding needs this second source. +/// +/// Returns `(HP term, predicate, UBERON|PATO term)` edges — `HasAnatomy` for a +/// UBERON filler, `HasQuality` for a PATO filler. Stateful line scan (balanced +/// `equivalentClass` depth); robust to the interleaved anonymous-class nesting. +#[must_use] +pub fn parse_hp_logical_defs(owl: &str) -> Vec<(TermId, Predicate, TermId)> { + let mut out = Vec::new(); + let mut cur: Option = None; + let mut in_eq: i32 = 0; + for line in owl.lines() { + // a named HP class opens a new subject + if let Some(hp) = find_hp_about(line) { + cur = Some(hp); + } + if line.contains("") { + in_eq += 1; + } + if in_eq > 0 + && let Some(sid) = cur { + for uid in find_obo_ids(line, "UBERON_") { + out.push(( + TermId { ns: Namespace::Hpo as u8, num: sid }, + Predicate::HasAnatomy, + TermId { ns: Namespace::Uberon as u8, num: uid }, + )); + } + for pid in find_obo_ids(line, "PATO_") { + out.push(( + TermId { ns: Namespace::Hpo as u8, num: sid }, + Predicate::HasQuality, + TermId { ns: Namespace::Pato as u8, num: pid }, + )); + } + } + if line.contains("") { + in_eq = (in_eq - 1).max(0); + } + } + out.sort_unstable(); + out.dedup(); + out +} + +/// Extract the numeric id from a `owl:Class rdf:about="…/HP_NNNNNNN"` line. +fn find_hp_about(line: &str) -> Option { + let i = line.find("owl:Class rdf:about=")?; + let rest = &line[i..]; + let j = rest.find("HP_")?; + let digits: String = rest[j + 3..].chars().take_while(char::is_ascii_digit).collect(); + digits.parse().ok() +} + +/// Find every `PREFIX_NNNNNNN` numeric id on a line (e.g. `UBERON_0000955`). +fn find_obo_ids(line: &str, prefix: &str) -> Vec { + let mut ids = Vec::new(); + let mut hay = line; + while let Some(i) = hay.find(prefix) { + let after = &hay[i + prefix.len()..]; + let digits: String = after.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() + && n <= 0x00FF_FFFF { + ids.push(n); + } + hay = &after[digits.len()..]; + } + ids +} + +/// Fold logical-definition edges (from [`parse_hp_logical_defs`]) into a parsed +/// node map — the merge that completes the HP→Uberon:PATO half of the backbone. +pub fn merge_logical_defs( + nodes: &mut std::collections::HashMap, + defs: &[(TermId, Predicate, TermId)], +) { + for (s, p, o) in defs { + nodes.entry(*s).or_default().rel.push((*p, *o)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn n(ns: Namespace, num: u32) -> TermId { + TermId { ns: ns as u8, num } + } + + #[test] + fn curie_parse_and_classid_canon() { + let t = TermId::parse("MONDO:0007739").unwrap(); + assert_eq!(t.namespace(), Namespace::Mondo); + assert_eq!(t.num, 7739); + // canon-high: (0x0301 << 16) | app_prefix ; all five in the 0x03 domain + assert_eq!(Namespace::Mondo.render_classid(0x0000), 0x0301_0000); + assert_eq!(Namespace::Uberon.render_classid(0x00AB), 0x0303_00AB); + // out-of-scope / malformed + assert!(TermId::parse("CHEBI:12345").is_none()); + assert!(TermId::parse("MONDO:99999999").is_none()); // > 24-bit + } + + #[test] + fn xrefs_are_preserved_never_truncated() { + let obo = "\ +[Term]\n\ +id: MONDO:0005148\n\ +name: type 2 diabetes mellitus\n\ +xref: MeSH:D003924\n\ +xref: ICD10:E11\n\ +xref: SNOMEDCT:44054006\n\ +is_a: MONDO:0005015 ! diabetes mellitus\n"; + let nodes = parse_obo(obo); + let t = n(Namespace::Mondo, 5148); + let node = &nodes[&t]; + assert_eq!(node.xref.len(), 3, "all three xrefs kept"); + assert!(node.xref.iter().any(|x| x.source == XrefSource::Mesh && x.id == "D003924")); + let bake = bake(&nodes, 0x0000); + assert_eq!(bake.stats.mesh_xrefs, 1, "MeSH bearing counted"); + assert_eq!(bake.stats.xrefs, 3); + } + + #[test] + fn bake_round_trips_through_the_lance_graph_loader_contract() { + // Build a tiny 3-node core and bake it. + let mut nodes: HashMap = HashMap::new(); + nodes.insert(n(Namespace::Uberon, 300), OboNode::default()); + nodes.insert( + n(Namespace::Uberon, 100), + OboNode { + is_a: vec![n(Namespace::Uberon, 300)], + ..Default::default() + }, + ); + nodes.insert( + n(Namespace::Hpo, 9), + OboNode { + rel: vec![(Predicate::HasAnatomy, n(Namespace::Uberon, 100))], + ..Default::default() + }, + ); + let baked = bake(&nodes, 0x0000); + assert_eq!(baked.rows.len(), 3); + + // The loader contract: as_le_bytes is 512×N and 64-aligned, and + // rows_from_le_bytes (the mirror of node_rows_from_le_bytes) accepts it + // and returns the SAME rows byte-for-byte — this is the cross-repo + // zero-copy proof. + let bytes = as_le_bytes(&baked.rows); + assert_eq!(bytes.len(), 3 * NODE_ROW_STRIDE); + assert_eq!(bytes.as_ptr() as usize % 64, 0, "64-aligned for zero-copy"); + let back = rows_from_le_bytes(bytes).expect("loader accepts the packet"); + assert_eq!(back.len(), 3); + for (a, b) in baked.rows.iter().zip(back) { + assert_eq!(a.0, b.0, "row bytes identical through the loader"); + } + // key decode: row 0 is the lowest (ns,num) = HP:9 (Hpo ordinal 1) vs + // Uberon ordinal 2 — ids sorted by (ns,num), so HP (ns=1) sorts first. + let (classid0, id0) = decode_key(&back[0]); + assert_eq!(id0, 9); + assert_eq!(classid0, Namespace::Hpo.render_classid(0x0000)); + + // A non-aligned / non-multiple buffer is rejected (loader would fall + // back to a copy) — the can-it-refuse half. + assert!(rows_from_le_bytes(&bytes[1..]).is_none()); + } + + #[test] + fn hp_base_logical_def_scan_extracts_grounding() { + // A minimal RDF/XML equivalentClass block in hp-base.owl shape. + let owl = r#" + + + + + + + + + + + + +"#; + let defs = parse_hp_logical_defs(owl); + assert!(defs.contains(&( + n(Namespace::Hpo, 1250), + Predicate::HasAnatomy, + n(Namespace::Uberon, 955) + ))); + assert!(defs.contains(&( + n(Namespace::Hpo, 1250), + Predicate::HasQuality, + n(Namespace::Pato, 1) + ))); + } +} diff --git a/crates/ogar-obo/src/reason.rs b/crates/ogar-obo/src/reason.rs new file mode 100644 index 0000000..95e6791 --- /dev/null +++ b/crates/ogar-obo/src/reason.rs @@ -0,0 +1,288 @@ +//! OWL-EL completion — the ELK subset the OBO EL profile actually exercises, +//! excavated onto plain Rust (no Java, no jar). OBO is deliberately OWL 2 EL, +//! so classification is **consequence-based saturation** over a small rule set; +//! the three rules that carry the OBO core: +//! +//! * **R⊑ (subsumption transitivity)** `A ⊑ B, B ⊑ C ⟹ A ⊑ C` — `is_a` +//! classification. +//! * **R∘ (transitive role)** `A ⊑ ∃r.B, B ⊑ ∃r.C ⟹ A ⊑ ∃r.C` for a +//! transitive `r` — `part_of` mereology on the anatomy backbone. +//! * **R∃ (existential + filler subsumption)** `A ⊑ ∃r.B, B ⊑ C ⟹ A ⊑ ∃r.C` +//! — an HP grounded to a specific Uberon site is grounded to that site's +//! `is_a`/`part_of` ancestors too. This is the deductive form of "inherit +//! the anatomy grounding up" (the Tier-1 completion, now a proof not a prior). +//! +//! Unsatisfiability (`A ⊑ ⊥`) needs disjointness/`owl:Nothing` axioms that the +//! base `.obo` does not carry, so [`ElStats::unsatisfiable`] is reported `0` +//! with that caveat — closing it is the `hp-base.owl` / disjointness pass. + +use crate::{Predicate, TermId, Triple}; +use std::collections::HashMap; + +/// Count `is_a` cycles (strongly-connected components of size > 1) — must be 0 +/// on a clean OBO core. Iterative Tarjan so a deep chain can't blow the stack. +#[must_use] +pub fn count_is_a_cycles(triples: &[Triple]) -> usize { + // child -> parents adjacency over is_a only + let mut adj: HashMap> = HashMap::new(); + let mut nodes: Vec = Vec::new(); + for t in triples { + if t.p == Predicate::IsA { + adj.entry(t.s).or_default().push(t.o); + nodes.push(t.s); + nodes.push(t.o); + } + } + nodes.sort_unstable(); + nodes.dedup(); + + let mut index: HashMap = HashMap::new(); + let mut low: HashMap = HashMap::new(); + let mut on_stack: HashMap = HashMap::new(); + let mut stack: Vec = Vec::new(); + let mut idx: u32 = 0; + let mut cycles = 0usize; + + // explicit DFS frames: (node, next-child-cursor) + let empty: Vec = Vec::new(); + for &root in &nodes { + if index.contains_key(&root) { + continue; + } + let mut call: Vec<(TermId, usize)> = vec![(root, 0)]; + while let Some(&(v, ci)) = call.last() { + if ci == 0 { + index.insert(v, idx); + low.insert(v, idx); + idx += 1; + stack.push(v); + on_stack.insert(v, true); + } + let children = adj.get(&v).unwrap_or(&empty); + if ci < children.len() { + let w = children[ci]; + call.last_mut().unwrap().1 += 1; + if !index.contains_key(&w) { + call.push((w, 0)); + } else if *on_stack.get(&w).unwrap_or(&false) { + let lw = index[&w]; + let lv = low[&v]; + low.insert(v, lv.min(lw)); + } + } else { + // done with v — pop SCC if root + if low[&v] == index[&v] { + let mut sz = 0; + while let Some(w) = stack.pop() { + on_stack.insert(w, false); + sz += 1; + if w == v { + break; + } + } + if sz > 1 { + cycles += 1; + } + } + call.pop(); + if let Some(&(parent, _)) = call.last() { + let lp = low[&parent]; + let lv = low[&v]; + low.insert(parent, lp.min(lv)); + } + } + } + } + cycles +} + +/// The aggregate EL-saturation counts over the OBO core. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ElStats { + /// transitive `is_a` pairs (the classification closure size) + pub subsumption_pairs: usize, + /// transitive `part_of` pairs (mereology closure size) + pub part_of_pairs: usize, + /// existential edges inferred by R∃ *beyond* the asserted ones — the + /// "grounding inherited up the anatomy `is_a`/`part_of` spine" count + pub existential_inferred: usize, + /// classes proven unsatisfiable (`A ⊑ ⊥`) — 0 without disjointness axioms + /// (base `.obo` carries none; see the module note) + pub unsatisfiable: usize, +} + +/// Topological ancestor DP over a single relation's `child -> parents` graph. +/// Returns, per node, the sorted deduped set of all transitive ancestors. +/// Assumes acyclic (run [`count_is_a_cycles`] first); a residual cycle is +/// simply not expanded past a re-visit, never loops. +fn ancestor_closure( + adj: &HashMap>, +) -> HashMap> { + // Kahn order over the parent graph, then DP bottom-up. + // Build reverse (parent -> children) for indegree over child->parent edges. + let mut all: Vec = Vec::new(); + for (c, ps) in adj { + all.push(*c); + all.extend(ps.iter().copied()); + } + all.sort_unstable(); + all.dedup(); + + // process nodes in an order where all parents precede a child: + // repeatedly resolve nodes whose parents are all resolved (memoized). + let mut memo: HashMap> = HashMap::new(); + let empty: Vec = Vec::new(); + // iterative post-order DFS to fill memo + for &start in &all { + if memo.contains_key(&start) { + continue; + } + let mut stack: Vec<(TermId, usize)> = vec![(start, 0)]; + while let Some(&(v, ci)) = stack.last() { + let parents = adj.get(&v).unwrap_or(&empty); + if ci < parents.len() { + let p = parents[ci]; + stack.last_mut().unwrap().1 += 1; + if !memo.contains_key(&p) && !stack.iter().any(|(x, _)| *x == p) { + stack.push((p, 0)); + } + } else { + if !memo.contains_key(&v) { + let mut anc: Vec = Vec::new(); + for &p in adj.get(&v).unwrap_or(&empty) { + anc.push(p); + if let Some(pa) = memo.get(&p) { + anc.extend(pa.iter().copied()); + } + } + anc.sort_unstable(); + anc.dedup(); + memo.insert(v, anc); + } + stack.pop(); + } + } + } + memo +} + +/// Run the EL completion subset over the triples and return the aggregate +/// counts. `is_a` and `part_of` closures via [`ancestor_closure`]; R∃ +/// propagates every existential filler up the combined `is_a`+`part_of` spine. +#[must_use] +pub fn saturate(triples: &[Triple]) -> ElStats { + let mut isa: HashMap> = HashMap::new(); + let mut partof: HashMap> = HashMap::new(); + // spine = is_a ∪ part_of (both propagate anatomy grounding upward) + let mut spine: HashMap> = HashMap::new(); + let mut existential: Vec<(TermId, Predicate, TermId)> = Vec::new(); + + for t in triples { + match t.p { + Predicate::IsA => { + isa.entry(t.s).or_default().push(t.o); + spine.entry(t.s).or_default().push(t.o); + } + Predicate::PartOf => { + partof.entry(t.s).or_default().push(t.o); + spine.entry(t.s).or_default().push(t.o); + existential.push((t.s, t.p, t.o)); + } + Predicate::HasAnatomy + | Predicate::HasQuality + | Predicate::HasLocation + | Predicate::HasPhenotype => { + existential.push((t.s, t.p, t.o)); + } + Predicate::Other => {} + } + } + + let isa_c = ancestor_closure(&isa); + let partof_c = ancestor_closure(&partof); + let spine_c = ancestor_closure(&spine); + + let subsumption_pairs: usize = isa_c.values().map(Vec::len).sum(); + let part_of_pairs: usize = partof_c.values().map(Vec::len).sum(); + // R∃: each existential (s,r,o) also holds for every ancestor of o on the + // is_a∪part_of spine — the inferred-beyond-asserted count. + let empty: Vec = Vec::new(); + let existential_inferred: usize = existential + .iter() + .map(|(_, _, o)| spine_c.get(o).unwrap_or(&empty).len()) + .sum(); + + ElStats { + subsumption_pairs, + part_of_pairs, + existential_inferred, + unsatisfiable: 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Namespace; + + fn t(sns: Namespace, s: u32, p: Predicate, ons: Namespace, o: u32) -> Triple { + Triple { + s: TermId { ns: sns as u8, num: s }, + p, + o: TermId { ns: ons as u8, num: o }, + } + } + + #[test] + fn no_cycle_on_a_chain() { + // A is_a B is_a C + let tr = vec![ + t(Namespace::Mondo, 1, Predicate::IsA, Namespace::Mondo, 2), + t(Namespace::Mondo, 2, Predicate::IsA, Namespace::Mondo, 3), + ]; + assert_eq!(count_is_a_cycles(&tr), 0); + } + + #[test] + fn detects_a_cycle() { + // A is_a B is_a A (a real ontology bug) + let tr = vec![ + t(Namespace::Mondo, 1, Predicate::IsA, Namespace::Mondo, 2), + t(Namespace::Mondo, 2, Predicate::IsA, Namespace::Mondo, 1), + ]; + assert_eq!(count_is_a_cycles(&tr), 1); + } + + #[test] + fn r_subsumption_transitive_closure() { + // A⊑B⊑C ⟹ ancestors: A={B,C}, B={C} = 3 pairs + let tr = vec![ + t(Namespace::Mondo, 1, Predicate::IsA, Namespace::Mondo, 2), + t(Namespace::Mondo, 2, Predicate::IsA, Namespace::Mondo, 3), + ]; + assert_eq!(saturate(&tr).subsumption_pairs, 3); + } + + #[test] + fn r_existential_grounding_inherited_up() { + // The load-bearing EL inference: + // HP:9 has_anatomy UBERON:100 (a specific site) + // UBERON:100 part_of UBERON:200 (site is part of a bigger site) + // UBERON:200 is_a UBERON:300 (which is a kind of ...) + // ⟹ HP:9 is grounded to UBERON:{200,300} too (2 inferred-beyond-asserted). + let tr = vec![ + t(Namespace::Hpo, 9, Predicate::HasAnatomy, Namespace::Uberon, 100), + t(Namespace::Uberon, 100, Predicate::PartOf, Namespace::Uberon, 200), + t(Namespace::Uberon, 200, Predicate::IsA, Namespace::Uberon, 300), + ]; + let s = saturate(&tr); + // R∃ fires on BOTH existentials: HP:9→{200,300} (2) AND the part_of + // edge 100→200 chains through 200 is_a 300 to give 100 part_of 300 (1). + // 3 inferred-beyond-asserted total — the part_of∘is_a chaining is a real + // OBO inference, not an artifact. + assert_eq!( + s.existential_inferred, 3, + "HP:9 grounds to UBERON:{{200,300}} and 100 part_of 300 chains" + ); + } +} diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 4bbe638..66c184a 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1102,7 +1102,7 @@ impl Class { /// 0x00XX reserved (0x0000 = NodeGuid::CLASSID_DEFAULT) /// 0x01XX project-mgmt (OP ↔ Redmine fork lineage) /// 0x02XX commerce / ERP (OSB ↔ Odoo cross-curator) -/// 0x03XX unassigned +/// 0x03XX Ontology (OBO biomedical reference: MONDO/HPO/Uberon/PATO/RO — zero rows here; concepts in ogar-obo) /// 0x04XX unassigned /// 0x05XX unassigned /// 0x06XX unassigned @@ -1204,6 +1204,19 @@ const CODEBOOK: &[(&str, u16)] = &[ ("pricelist", 0x0209), ("pricelist_rule", 0x020A), ("unit_of_measure", 0x020B), + // ── 0x03XX — Ontology domain: ZERO vocabulary rows BY DESIGN ── + // Public OBO biomedical reference ontologies (MONDO disease · HPO + // phenotype · Uberon anatomy · PATO quality · RO relations). Same posture + // as the 0x07XX OSINT and 0x0EXX Genetics blocks: the domain slot is + // RESERVED (`ConceptDomain::Ontology`) so `canonical_concept_domain` + // returns a stable tag, but the concept ids are NOT minted as shared + // CODEBOOK rows — they live in the producer crate `ogar-obo` + // (`Namespace::concept_id`: mondo 0x0301 · hpo 0x0302 · uberon 0x0303 · + // pato 0x0304 · ro 0x0305). This keeps the OBO reference PLUG-AND-PLAY: + // only a consumer that deps `ogar-obo` compiles the concepts; ERP / project + // consumers (odoo-rs, openproject-nexgen-rs, …) never pull them into their + // concept space. Public reference, firewall-separated from `0x09` Health + // PHI — same reference≠PHI split as Anatomy (0x0A). Do NOT mint rows here. // ── 0x07XX — OSINT domain: ZERO vocabulary rows BY DESIGN (operator // ruling 2026-07-02, corrects PR #145's two hallucinated concept mints // `osint_system@0x0700` / `osint_person@0x0701`). Within the OSINT domain @@ -1382,6 +1395,15 @@ pub enum ConceptDomain { ProjectMgmt, /// `0x02XX` — commerce / billing / ERP (OSB ↔ Odoo). Commerce, + /// `0x03XX` — Ontology (OBO biomedical reference: MONDO / HPO / Uberon / + /// PATO / RO). Carries ZERO shared vocabulary rows — same posture as + /// [`Osint`](Self::Osint) / [`Genetics`](Self::Genetics): the domain slot + /// is reserved so `canonical_concept_domain` returns a stable tag, but the + /// concept ids live in the producer crate `ogar-obo` + /// (`Namespace::concept_id`), so the OBO reference stays plug-and-play and + /// never pulls into ERP / project consumers. Public reference, NOT PHI — + /// same reference≠PHI split as [`Anatomy`](Self::Anatomy). + Ontology, /// `0x07XX` — OSINT (open-source intelligence). Osint, /// `0x08XX` — OCR (optical character recognition / document @@ -1434,7 +1456,7 @@ pub enum ConceptDomain { /// geodata, NOT PHI; same public-reference posture as /// [`Anatomy`](Self::Anatomy). Geo, - /// Any high-byte slot not yet assigned a domain (`0x03XX`–`0x06XX`, + /// Any high-byte slot not yet assigned a domain (`0x04XX`–`0x06XX`, /// `0x10XX`+). Unassigned, } @@ -1447,6 +1469,7 @@ pub fn canonical_concept_domain(id: u16) -> ConceptDomain { 0x00 => ConceptDomain::Reserved, 0x01 => ConceptDomain::ProjectMgmt, 0x02 => ConceptDomain::Commerce, + 0x03 => ConceptDomain::Ontology, 0x07 => ConceptDomain::Osint, 0x08 => ConceptDomain::Ocr, 0x09 => ConceptDomain::Health, @@ -5473,8 +5496,12 @@ mod tests { // Automation block (0x0C) — HIRO IT-automation stack. assert_eq!(canonical_concept_domain(0x0C00), ConceptDomain::Automation); assert_eq!(canonical_concept_domain(0x0C09), ConceptDomain::Automation); - // Unassigned blocks (3-6). - assert_eq!(canonical_concept_domain(0x0300), ConceptDomain::Unassigned); + // Ontology block (0x03) — reserved, zero concept rows (OBO reference + // lives in ogar-obo; plug-and-play, never pulls into ERP consumers). + assert_eq!(canonical_concept_domain(0x0300), ConceptDomain::Ontology); + assert_eq!(canonical_concept_domain(0x03AB), ConceptDomain::Ontology); + // Unassigned blocks (4-6). + assert_eq!(canonical_concept_domain(0x0400), ConceptDomain::Unassigned); assert_eq!(canonical_concept_domain(0x0600), ConceptDomain::Unassigned); // HR block (0x0D). assert_eq!(canonical_concept_domain(0x0D00), ConceptDomain::HR);