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);