diff --git a/crates/ogar-obo/src/edges.rs b/crates/ogar-obo/src/edges.rs index 6a7ca30e..66f727e3 100644 --- a/crates/ogar-obo/src/edges.rs +++ b/crates/ogar-obo/src/edges.rs @@ -82,9 +82,12 @@ pub const LANE_BYTES: usize = 16; /// Links per lane under the G2 `4×u24` carving. pub const LINKS_PER_LANE: usize = 4; /// First edge lane's offset **within the 480-byte value slab** (lane 7). -pub const EDGE_LANE_SLAB_OFFSET: usize = 112; -/// Number of lanes reserved for edge targets (lanes 7..30). -pub const EDGE_LANE_COUNT: usize = 23; +/// Derived from the per-class LE contract ([`crate::layout::OBO_CORE_ROW`]), +/// never an independent literal. +pub const EDGE_LANE_SLAB_OFFSET: usize = crate::layout::OBO_CORE_ROW.edge_lanes.slab_off; +/// Number of lanes reserved for edge targets (lanes 7..30). Derived from the +/// per-class LE contract, never an independent literal. +pub const EDGE_LANE_COUNT: usize = crate::layout::OBO_CORE_ROW.lane_count; /// Total link slots per row. pub const EDGE_SLOT_COUNT: usize = EDGE_LANE_COUNT * LINKS_PER_LANE; diff --git a/crates/ogar-obo/src/layout.rs b/crates/ogar-obo/src/layout.rs new file mode 100644 index 00000000..dd863dc7 --- /dev/null +++ b/crates/ogar-obo/src/layout.rs @@ -0,0 +1,162 @@ +//! The **per-class little-endian row contract** of the OBO-core bake — the +//! carve DECLARED once, per concept, instead of re-typed as literals at every +//! reader. +//! +//! # Why this module exists (the 2026-08-13 drift finding) +//! +//! The value-slab carve of this bake was written down as bare literals +//! (`ENTITY_TYPE_SLAB_OFFSET = 96`, `EDGE_LANE_SLAB_OFFSET = 112`) — and the +//! same literals were hand-mirrored by a downstream consumer. Two repos agreed +//! with each other while both diverged from the generic default tenant carve: +//! a reader resolving `EntityType` through the DEFAULT descriptor reads a +//! different offset than the one this bake writes. The literals asserted +//! self-consistency; nothing declared the class's OWN reading. +//! +//! This module is that declaration: **the classid picks the reading** (the +//! ClassView move). Every lens adapter over these rows — the spine lens, the +//! edge lanes, an ELK observer, a CURIE resolver, a downstream rails carve — +//! derives its offsets from [`ClassRowSchema`], and a consumer pins its mirror +//! against this schema in a bridge test instead of re-typing bytes. +//! +//! # Scope, honestly +//! +//! The schema declares what THIS bake writes. It intentionally differs from +//! the generic default value-tenant carve — that is now a **declared per-class +//! carve**, not a silent drift — and the registry-side registration of this +//! schema as the `0x03xx` classes' `ValueSchema` (so a canon reader resolves +//! it through `ClassView` rather than through this crate) is the paired +//! follow-up, adjudicated at the contract's home, not here. + +use crate::registry; + +/// One declared little-endian field region of a class's 512-byte row, offsets +/// **relative to the 480-byte value slab** (row offset = [`crate::VALUE_OFFSET`] +/// + `slab_off`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RowField { + /// Value-slab-relative byte offset of the field's first byte. + pub slab_off: usize, + /// The field's width in bytes. + pub bytes: usize, +} + +impl RowField { + /// The field's absolute row offset (key + edge block skipped). + #[must_use] + pub const fn row_off(&self) -> usize { + crate::VALUE_OFFSET + self.slab_off + } + + /// One past the field's last value-slab-relative byte. + #[must_use] + pub const fn slab_end(&self) -> usize { + self.slab_off + self.bytes + } +} + +/// The little-endian row contract a classid family declares — where, inside +/// the canonical `key(16) | edges(16) | value(480)` row, THIS class's value +/// tenants actually sit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClassRowSchema { + /// The `EntityType` tenant (`u16` namespace ordinal, little-endian). + pub entity_type: RowField, + /// The edge-lane slab (`lane_count` lanes of [`crate::edges::LANE_BYTES`] + /// each; each lane `classid(4) + 4×u24` under the G2 grace carving). + pub edge_lanes: RowField, + /// Number of edge lanes inside `edge_lanes`. + pub lane_count: usize, +} + +/// The one row schema every concept this crate bakes declares today. A future +/// class that needs a different carve declares its OWN `ClassRowSchema` and is +/// resolved through [`row_schema_of`] — never by widening or moving this one +/// (RESERVE, DON'T RECLAIM). +pub const OBO_CORE_ROW: ClassRowSchema = ClassRowSchema { + entity_type: RowField { + slab_off: 96, + bytes: 2, + }, + edge_lanes: RowField { + slab_off: 112, + bytes: 23 * 16, + }, + lane_count: 23, +}; + +// The schema is self-consistent and fills the row exactly: EntityType ends +// before the lanes begin, and the lanes run to the end of the 480-byte slab. +const _: () = assert!(OBO_CORE_ROW.entity_type.slab_end() <= OBO_CORE_ROW.edge_lanes.slab_off); +const _: () = assert!( + crate::VALUE_OFFSET + OBO_CORE_ROW.edge_lanes.slab_end() == crate::NODE_ROW_STRIDE, + "the edge lanes must run to the end of the row" +); +const _: () = assert!(OBO_CORE_ROW.edge_lanes.bytes == OBO_CORE_ROW.lane_count * 16); + +/// Resolve a concept id (the hi-u16 of a render classid) to its declared row +/// schema — the ClassView move: **the classid picks the reading**, a reader +/// never assumes a carve. +/// +/// `None` for anything this crate does not bake — including `0x0306` +/// (`ogar-ro`'s relation-BODY classid, whose 512-byte row is `ogar-loco` +/// call-slab shaped, not this schema) and every concept outside the +/// registries. Refused, not guessed. +#[must_use] +pub fn row_schema_of(concept: u16) -> Option<&'static ClassRowSchema> { + let known = registry::OBO_CORE + .specs() + .iter() + .chain(registry::META_STUDY_SPINE.specs()) + .any(|s| s.concept_id == concept); + known.then_some(&OBO_CORE_ROW) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_baked_concept_resolves_to_its_declared_schema() { + for s in registry::OBO_CORE + .specs() + .iter() + .chain(registry::META_STUDY_SPINE.specs()) + { + assert_eq!( + row_schema_of(s.concept_id), + Some(&OBO_CORE_ROW), + "{} must declare its row carve", + s.prefix + ); + } + } + + #[test] + fn foreign_concepts_are_refused_not_guessed() { + // 0x0306 is ogar-ro's relation-body classid — call-slab shaped, NOT + // this row schema. Handing it this carve would misread every byte. + assert_eq!(row_schema_of(0x0306), None); + // outside the ontology domain entirely + assert_eq!(row_schema_of(0x0A01), None); + assert_eq!(row_schema_of(0x0000), None); + } + + #[test] + fn the_declared_schema_is_the_carve_the_writers_use() { + // The literals the bake writes through are DERIVED from this schema — + // if someone re-introduces an independent literal, this pins the drift. + assert_eq!( + crate::ENTITY_TYPE_SLAB_OFFSET, + OBO_CORE_ROW.entity_type.slab_off + ); + assert_eq!( + crate::edges::EDGE_LANE_SLAB_OFFSET, + OBO_CORE_ROW.edge_lanes.slab_off + ); + assert_eq!(crate::edges::EDGE_LANE_COUNT, OBO_CORE_ROW.lane_count); + assert_eq!( + crate::edges::EDGE_LANE_COUNT * crate::edges::LANE_BYTES, + OBO_CORE_ROW.edge_lanes.bytes + ); + } +} diff --git a/crates/ogar-obo/src/lib.rs b/crates/ogar-obo/src/lib.rs index f1fd1e28..2293dc3e 100644 --- a/crates/ogar-obo/src/lib.rs +++ b/crates/ogar-obo/src/lib.rs @@ -42,6 +42,7 @@ pub mod crosswalk; pub mod edges; +pub mod layout; #[cfg(feature = "rdf")] pub mod rdf; pub mod reason; @@ -59,11 +60,15 @@ pub const NODE_ROW_STRIDE: usize = 512; 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; +/// `EntityType` value-tenant offset **within the 480-byte value slab** — a +/// `u16` recording this node's OBO namespace so a reader can group by +/// ontology without decoding the classid. +/// +/// **Derived from the per-class LE contract** ([`layout::OBO_CORE_ROW`]), +/// never an independent literal: this bake's carve is a DECLARED per-class +/// reading (it differs from the generic default tenant carve on purpose — +/// see [`layout`] for the drift history and the declaration that retired it). +pub const ENTITY_TYPE_SLAB_OFFSET: usize = layout::OBO_CORE_ROW.entity_type.slab_off; /// The five OBO-core namespaces this bake carries. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -135,6 +140,26 @@ impl Namespace { pub const fn render_classid(self, app_prefix: u16) -> u32 { ((self.concept_id() as u32) << 16) | (app_prefix as u32) } + + /// The inverse of [`Namespace::concept_id`] — resolve a classid's hi-u16 + /// concept back to the OBO namespace, or `None` outside the core five. + /// + /// The **single home** of the classid↔namespace reading (the render-classid + /// carve is canon-high `concept << 16 | app_prefix`, so the concept is + /// `(classid >> 16) as u16`). Consumers must derive through this rather + /// than re-arithmetic the carve locally — the 2026-08-13 crosswalk audit + /// found that inverse hand-written downstream. + #[must_use] + pub const fn from_concept_id(concept: u16) -> Option { + Some(match concept { + 0x0301 => Namespace::Mondo, + 0x0302 => Namespace::Hpo, + 0x0303 => Namespace::Uberon, + 0x0304 => Namespace::Pato, + 0x0305 => Namespace::Ro, + _ => return None, + }) + } } /// The RO-predicate byte palette carried on edges (the `P` of an SPO triple). @@ -160,6 +185,38 @@ pub enum Predicate { Other = 7, } +impl Predicate { + /// The wire/lane ordinal — the enum discriminant, the byte the edge block, + /// the edge lanes, and every consumer-side RO lane carry. + /// + /// This pair (`ordinal` / [`Predicate::from_ordinal`]) is the **single + /// home** of the byte↔predicate reading. A consumer that writes its own + /// `match b { 2 => PartOf, … }` creates a second copy of this table that + /// drifts silently when a predicate is minted — exactly the + /// second-projection failure the 2026-08-13 crosswalk audit measured in + /// two downstream repos. + #[must_use] + pub const fn ordinal(self) -> u8 { + self as u8 + } + + /// The inverse of [`Predicate::ordinal`]. `0` (reserved / unset) and + /// anything past the mint is refused, never guessed. + #[must_use] + pub const fn from_ordinal(o: u8) -> Option { + Some(match o { + 1 => Predicate::IsA, + 2 => Predicate::PartOf, + 3 => Predicate::HasPhenotype, + 4 => Predicate::HasLocation, + 5 => Predicate::HasAnatomy, + 6 => Predicate::HasQuality, + 7 => Predicate::Other, + _ => return None, + }) + } +} + /// A parsed OBO term id: `(namespace, 24-bit numeric)`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TermId { diff --git a/crates/ogar-obo/src/reason.rs b/crates/ogar-obo/src/reason.rs index 093fe2d4..2fd9dbf7 100644 --- a/crates/ogar-obo/src/reason.rs +++ b/crates/ogar-obo/src/reason.rs @@ -16,7 +16,7 @@ //! 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::{Namespace, OboNode, Predicate, TermId, Triple}; +use crate::{Namespace, Predicate, TermId, Triple}; use std::collections::{HashMap, HashSet}; /// Count `is_a` cycles (strongly-connected components of size > 1) — must be 0 @@ -218,19 +218,155 @@ pub fn saturate(triples: &[Triple]) -> ElStats { } } -// ── per-term reasoning walk over parse_obo output ────────────────────────── +// ── per-term reasoning walk, generic over its edge source ────────────────── // // `saturate` gives the aggregate EL closure; a consumer resolving ONE entity -// (a disease → its anatomy site + phenotypes) needs the per-term walk. These -// operate directly on [`crate::parse_obo`]'s `id -> node` map, applying the -// same is_a-saturation + existential-propagation rules to a single subject. - -/// Transitive `is_a` ancestry of `id` over [`crate::parse_obo`] output (the -/// term itself excluded), deduped, in deterministic order, depth-capped. The -/// start id is pre-marked seen, so a cyclic `is_a` (incl. a self-parent) can -/// never re-emit the query term. +// (a disease → its anatomy site + phenotypes) needs the per-term walk. The +// walk applies the same is_a-saturation + existential-propagation rules to a +// single subject — and it is generic over WHERE the asserted edges come from, +// so the same rules run over the pre-bake parse map at join time and over the +// baked lanes at read time, without a consumer ever rebuilding the map from +// rows it already holds (the 2026-08-13 crosswalk audit's headline finding). + +/// Where the per-term walk reads a subject's **asserted** edges from. +/// +/// **SoA lenses only — never a map.** The operator ruling (2026-08-13): a +/// JOIN exists only **cross-domain** (a crosswalk resolving a foreign code +/// into this domain's address space, upstream of the bake); **inner-domain +/// edges live in the live SoA substrate** and are read there, in place. The +/// pre-bake parse map ([`crate::parse_obo`]'s output) is bake input and does +/// NOT implement this trait — a reasoning path holding a parsed map beside +/// baked rows is the second-projection the 2026-08-13 crosswalk audit +/// measured, and it is structurally excluded here rather than discouraged. +/// +/// Shipped sources: +/// +/// * [`SpineSource`] — a borrowed lens over one baked slab, decoding edge +/// lanes in place; +/// * [`Stacked`] — **SoA lens stacking**: several borrowed sources layered +/// into one, for the deployment shape where a domain's edge families live +/// in more than one slab (crystal spine + a sidecar SoA of cross-angle +/// lanes). When the primary slab cannot carry an edge family, the answer +/// is a **sidecar SoA under the same LE contract, stacked** — never a +/// hand-rolled exception container. +/// +/// Both visitors must yield a deterministic order for a given source (the +/// walk's output order is defined by it). +pub trait EdgeSource { + /// Visit the asserted `is_a` parents of `id`. Unknown ids yield nothing, + /// silently — an edge into an unloaded ontology is a boundary, not a fault. + fn is_a(&self, id: TermId, visit: &mut dyn FnMut(TermId)); + /// Visit the asserted typed relations `(predicate, object)` of `id` + /// (everything except `is_a`). + fn rel(&self, id: TermId, visit: &mut dyn FnMut(Predicate, TermId)); +} + +/// **SoA lens stacking** — several borrowed [`EdgeSource`] layers read as one. +/// +/// Layers are visited in the given order, so the stack's determinism is the +/// layers' determinism plus their order. Duplicate suppression is the walk's +/// job (its `seen` set), not the stack's — stacking stays a pure read. +/// +/// This is the sanctioned shape for "the spine slab has `is_a`, the sidecar +/// slab has the cross-angle relations": two lenses, one stack, zero rebuild. +#[derive(Clone, Copy)] +pub struct Stacked<'a> { + layers: &'a [&'a dyn EdgeSource], +} + +impl<'a> Stacked<'a> { + /// Stack borrowed layers, visited in order. + #[must_use] + pub const fn new(layers: &'a [&'a dyn EdgeSource]) -> Self { + Self { layers } + } +} + +impl core::fmt::Debug for Stacked<'_> { + /// Prints the layer COUNT, never the layers (a layer can lens megabytes). + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Stacked") + .field("layers", &self.layers.len()) + .finish() + } +} + +impl EdgeSource for Stacked<'_> { + fn is_a(&self, id: TermId, visit: &mut dyn FnMut(TermId)) { + for l in self.layers { + l.is_a(id, visit); + } + } + + fn rel(&self, id: TermId, visit: &mut dyn FnMut(Predicate, TermId)) { + for l in self.layers { + l.rel(id, visit); + } + } +} + +/// [`crate::spine::SpineLens`] lifted into [`TermId`] space — the baked-lane +/// [`EdgeSource`]. +/// +/// Address translation is derived, never re-arithmetic'd: `TermId → classid` +/// through [`Namespace::render_classid`] under the bake's `app_prefix`, and +/// `classid → TermId` through [`Namespace::from_concept_id`] on the hi-u16. +/// A link into a concept outside the core five (or into a row not in view) is +/// dropped silently — the same boundary rule the lens itself follows. +#[derive(Clone, Copy, Debug)] +pub struct SpineSource<'a> { + lens: crate::spine::SpineLens<'a>, + app_prefix: u16, +} + +impl<'a> SpineSource<'a> { + /// Borrow a lens over rows baked under `app_prefix`. + #[must_use] + pub const fn new(lens: crate::spine::SpineLens<'a>, app_prefix: u16) -> Self { + Self { lens, app_prefix } + } + + /// `(classid, num)` → [`TermId`], via the namespace's own concept-id + /// inverse. `None` outside the OBO core. + fn term_of(classid: u32, num: u32) -> Option { + let ns = Namespace::from_concept_id((classid >> 16) as u16)?; + Some(TermId { ns: ns as u8, num }) + } +} + +impl EdgeSource for SpineSource<'_> { + fn is_a(&self, id: TermId, visit: &mut dyn FnMut(TermId)) { + let classid = id.namespace().render_classid(self.app_prefix); + self.lens.parents_of(classid, id.num, &mut |c, n| { + if let Some(t) = Self::term_of(c, n) { + visit(t); + } + }); + } + + fn rel(&self, id: TermId, visit: &mut dyn FnMut(Predicate, TermId)) { + let classid = id.namespace().render_classid(self.app_prefix); + if let Some(i) = self.lens.resolve(classid, id.num) { + self.lens.rel_at(i, &mut |p, c, n| { + if let Some(t) = Self::term_of(c, n) { + visit(p, t); + } + }); + } + } +} + +/// Transitive `is_a` ancestry of `id` over any [`EdgeSource`] (the term itself +/// excluded), deduped, in deterministic order, depth-capped. The start id is +/// pre-marked seen, so a cyclic `is_a` (incl. a self-parent) can never +/// re-emit the query term. +/// +/// The returned `Vec` is the caller's transient answer, never a cache — an +/// ancestor is the same KIND as a parent (a member, not a fact about the +/// set), so the closure is a projection under the zero-copy law: asking twice +/// walks twice, deliberately. #[must_use] -pub fn ancestors(nodes: &HashMap, id: TermId) -> Vec { +pub fn ancestors(src: &S, id: TermId) -> Vec { let mut seen = HashSet::new(); seen.insert(id); let mut out = Vec::new(); @@ -239,14 +375,12 @@ pub fn ancestors(nodes: &HashMap, id: TermId) -> Vec { frontier.sort_unstable(); let mut next = Vec::new(); for f in frontier.drain(..) { - if let Some(n) = nodes.get(&f) { - for &p in &n.is_a { - if seen.insert(p) { - out.push(p); - next.push(p); - } + src.is_a(f, &mut |p| { + if seen.insert(p) { + out.push(p); + next.push(p); } - } + }); } if next.is_empty() { break; @@ -260,23 +394,21 @@ pub fn ancestors(nodes: &HashMap, id: TermId) -> Vec { /// existential-role propagation up the subsumption spine, for a single subject. /// Deduped, deterministic (term-local edges first, then up the ancestry). #[must_use] -pub fn related_via_ancestry( - nodes: &HashMap, +pub fn related_via_ancestry( + src: &S, id: TermId, pred: Predicate, ) -> Vec { let mut chain = vec![id]; - chain.extend(ancestors(nodes, id)); + chain.extend(ancestors(src, id)); let mut seen = HashSet::new(); let mut out = Vec::new(); for c in chain { - if let Some(n) = nodes.get(&c) { - for &(p, obj) in &n.rel { - if p == pred && seen.insert(obj) { - out.push(obj); - } + src.rel(c, &mut |p, obj| { + if p == pred && seen.insert(obj) { + out.push(obj); } - } + }); } out } @@ -284,8 +416,8 @@ pub fn related_via_ancestry( /// The term's anatomy sites (`disease_has_location` → Uberon, classified by the /// Mondo→Uberon namespace pair), propagated up the `is_a` ancestry. #[must_use] -pub fn anatomy_of(nodes: &HashMap, id: TermId) -> Vec { - related_via_ancestry(nodes, id, Predicate::HasLocation) +pub fn anatomy_of(src: &S, id: TermId) -> Vec { + related_via_ancestry(src, id, Predicate::HasLocation) .into_iter() .filter(|t| t.namespace() == Namespace::Uberon) .collect() @@ -294,8 +426,8 @@ pub fn anatomy_of(nodes: &HashMap, id: TermId) -> Vec { /// The term's phenotypes (`has_phenotype` / `disease_has_feature` → HPO, /// classified by the Mondo→Hpo namespace pair), propagated up the ancestry. #[must_use] -pub fn phenotypes_of(nodes: &HashMap, id: TermId) -> Vec { - related_via_ancestry(nodes, id, Predicate::HasPhenotype) +pub fn phenotypes_of(src: &S, id: TermId) -> Vec { + related_via_ancestry(src, id, Predicate::HasPhenotype) .into_iter() .filter(|t| t.namespace() == Namespace::Hpo) .collect() @@ -390,8 +522,11 @@ mod tests { ); } + /// The per-term walk over the BAKED LANES — parse feeds the bake, the walk + /// reads the rows. The parse map itself is not a source (inner-domain is + /// the live SoA substrate; the map is join-stage input only). #[test] - fn per_term_walk_resolves_ancestry_anatomy_phenotype() { + fn per_term_walk_resolves_ancestry_anatomy_phenotype_over_the_baked_lanes() { // A disease with its anatomy edge on an is_a PARENT — the walk must // inherit it up the subsumption spine. let obo = "[Term]\n\ @@ -403,22 +538,82 @@ relationship: disease_has_feature HP:0012735 ! Cough\n\ [Term]\n\ id: MONDO:0000270\n\ name: lower respiratory tract disorder\n\ -relationship: disease_has_location UBERON:0001558 ! lower respiratory tract\n"; - let nodes = crate::parse_obo(obo); +relationship: disease_has_location UBERON:0001558 ! lower respiratory tract\n\ +\n\ +[Term]\n\ +id: HP:0012735\n\ +name: Cough\n\ +\n\ +[Term]\n\ +id: UBERON:0001558\n\ +name: lower respiratory tract\n"; + let baked = crate::bake(&crate::parse_obo(obo), 0x0000); + let lens = crate::spine::SpineLens::new(&baked.rows); + assert!(lens.is_sorted(), "the bake's own order carries the lens"); + let src = SpineSource::new(lens, 0x0000); let pneu = TermId::parse("MONDO:0005249").unwrap(); assert!( - ancestors(&nodes, pneu).contains(&TermId::parse("MONDO:0000270").unwrap()), + ancestors(&src, pneu).contains(&TermId::parse("MONDO:0000270").unwrap()), "is_a ancestry" ); assert!( - anatomy_of(&nodes, pneu).contains(&TermId::parse("UBERON:0001558").unwrap()), + anatomy_of(&src, pneu).contains(&TermId::parse("UBERON:0001558").unwrap()), "disease_has_location → Uberon, inherited from the is_a parent" ); assert!( - phenotypes_of(&nodes, pneu).contains(&TermId::parse("HP:0012735").unwrap()), + phenotypes_of(&src, pneu).contains(&TermId::parse("HP:0012735").unwrap()), "disease_has_feature → HPO (own edge)" ); // self excluded from its own ancestry (cycle-safe) - assert!(!ancestors(&nodes, pneu).contains(&pneu)); + assert!(!ancestors(&src, pneu).contains(&pneu)); + } + + /// SoA lens STACKING: the subsumption spine in one slab, the cross-angle + /// relations in a SIDECAR slab — the stack answers what neither slab can + /// alone (the anatomy edge sits on the parent, and the parent link sits in + /// the OTHER slab). This is the deployment shape the trait exists for. + #[test] + fn a_sidecar_slab_stacks_onto_the_spine_without_any_rebuild() { + // slab 1: the is_a spine only + let spine_obo = "[Term]\n\ +id: MONDO:0005249\n\ +name: pneumonia\n\ +is_a: MONDO:0000270 ! lower respiratory tract disorder\n\ +\n\ +[Term]\n\ +id: MONDO:0000270\n\ +name: lower respiratory tract disorder\n"; + // slab 2 (sidecar): the cross-angle relation only + let sidecar_obo = "[Term]\n\ +id: MONDO:0000270\n\ +name: lower respiratory tract disorder\n\ +relationship: disease_has_location UBERON:0001558 ! lower respiratory tract\n\ +\n\ +[Term]\n\ +id: UBERON:0001558\n\ +name: lower respiratory tract\n"; + let spine_bake = crate::bake(&crate::parse_obo(spine_obo), 0x0000); + let sidecar_bake = crate::bake(&crate::parse_obo(sidecar_obo), 0x0000); + let spine = SpineSource::new(crate::spine::SpineLens::new(&spine_bake.rows), 0x0000); + let sidecar = SpineSource::new(crate::spine::SpineLens::new(&sidecar_bake.rows), 0x0000); + let layers: [&dyn EdgeSource; 2] = [&spine, &sidecar]; + let stack = Stacked::new(&layers); + + let pneu = TermId::parse("MONDO:0005249").unwrap(); + let site = TermId::parse("UBERON:0001558").unwrap(); + // Neither slab alone can resolve the anatomy… + assert!( + anatomy_of(&spine, pneu).is_empty(), + "the spine slab carries no relation" + ); + assert!( + anatomy_of(&sidecar, pneu).is_empty(), + "the sidecar slab carries no ancestry into the relation" + ); + // …the STACK can: ancestry from slab 1, relation from slab 2. + assert!( + anatomy_of(&stack, pneu).contains(&site), + "stacked lenses compose ancestry × cross-angle" + ); } } diff --git a/crates/ogar-obo/src/rf2.rs b/crates/ogar-obo/src/rf2.rs index 2c9e9ac4..ab0159fa 100644 --- a/crates/ogar-obo/src/rf2.rs +++ b/crates/ogar-obo/src/rf2.rs @@ -146,7 +146,11 @@ mod tests { fn sctids_are_u64_and_refuse_rather_than_truncate() { let s = parse_snapshot(OK).expect("parses"); assert_eq!(s.sctid(1, "referencedComponentId"), Some(16898231000119107)); - assert_eq!(s.sctid(0, "mapTarget"), None, "E11 is not numeric — refused"); + assert_eq!( + s.sctid(0, "mapTarget"), + None, + "E11 is not numeric — refused" + ); assert_eq!(s.sctid(0, "nope"), None, "absent column — refused"); } diff --git a/crates/ogar-obo/src/spine.rs b/crates/ogar-obo/src/spine.rs index fa3378a7..597cffdf 100644 --- a/crates/ogar-obo/src/spine.rs +++ b/crates/ogar-obo/src/spine.rs @@ -131,6 +131,22 @@ impl<'a> SpineLens<'a> { } } + /// Visit every **typed relation** (everything except `is_a`) of the row at + /// `i`, decoded in place — one lane sweep, not one sweep per predicate. + /// + /// The typed twin of [`Self::links_at`]: `is_a` is excluded because the + /// subsumption spine has its own reader ([`Self::parents_of`]) with its own + /// safety note, and a caller asking for "the relations" almost never wants + /// subsumption mixed in silently. + pub fn rel_at(&self, i: usize, visit: &mut dyn FnMut(Predicate, u32, u32)) { + let Some(row) = self.rows.get(i) else { return }; + for (p, l) in EdgeLanes::new(&row.0).links() { + if p != Predicate::IsA { + visit(p, l.classid, l.num); + } + } + } + /// Visit the **asserted superclasses** of an address: resolve, then walk /// that row's `is_a` links. /// diff --git a/crates/ogar-ro/src/lib.rs b/crates/ogar-ro/src/lib.rs index 9da3fcb5..9084bf08 100644 --- a/crates/ogar-ro/src/lib.rs +++ b/crates/ogar-ro/src/lib.rs @@ -193,6 +193,51 @@ relation_palette! { 0xA5 => CONFOUNDS_TEST, "confounds_test", "LOCAL:confounds_test"; } +/// Position lookup over the palette's contiguous slot band — resolving an +/// [`FnIndex`] is an index computation, never a scan. +/// +/// The palette mints slots contiguously from [`IS_A`] (`0x90`) upward, so +/// `RELATIONS[f - 0x90]` IS the row ("Position trägt die Ebene" — the same +/// argument the consumer rail slabs make). A byte below the floor or past the +/// mint is refused, not guessed. The contiguity itself is pinned by +/// `the_slot_band_is_contiguous_so_position_is_the_lookup` — a future gap in +/// the band fails THERE, loudly, instead of silently misaligning this lookup. +/// +/// This is the **single home** of the `FnIndex → predicate` reading. A +/// consumer that round-trips byte → mnemonic string → byte (name-keyed scan) +/// re-creates the string join this palette exists to delete. +#[must_use] +pub const fn by_index(f: FnIndex) -> Option<&'static RelationPredicate> { + let i = f.0.wrapping_sub(IS_A.0) as usize; + if i < RELATIONS.len() { + Some(&RELATIONS[i]) + } else { + None + } +} + +/// The `ogar_obo::Predicate` → palette-row crossing — **one home** for the +/// mapping both sides of the OBO/RO seam need, so no consumer ever writes the +/// ordinal↔mnemonic table a second time. +/// +/// `IsA`/`PartOf`/`HasPhenotype`/`HasLocation`/`HasQuality` map onto their +/// minted rows. `HasAnatomy` (an existential harvested from HPO logical +/// definitions) and `Other` (the generic preserve-everything bucket) have no +/// palette row — `None`, honestly, rather than a lookalike. +#[must_use] +pub const fn for_obo_predicate(p: ogar_obo::Predicate) -> Option<&'static RelationPredicate> { + use ogar_obo::Predicate as P; + let f = match p { + P::IsA => IS_A, + P::PartOf => PART_OF, + P::HasPhenotype => HAS_PHENOTYPE, + P::HasLocation => DISEASE_HAS_LOCATION, + P::HasQuality => HAS_QUALITY, + P::HasAnatomy | P::Other => return None, + }; + by_index(f) +} + /// The RO/BFO relation palette as an `ogar-loco` [`Vocabulary`]. /// /// Every minted predicate is a **binary assertion**: it pops two operands @@ -320,6 +365,47 @@ mod tests { assert!(seen.len() >= 12, "palette shrank to {}", seen.len()); } + #[test] + fn the_slot_band_is_contiguous_so_position_is_the_lookup() { + // by_index's soundness condition, pinned: slot i sits at IS_A.0 + i. + // A future mint that leaves a gap fails HERE, not by misalignment. + for (i, r) in RELATIONS.iter().enumerate() { + assert_eq!( + r.index.0, + IS_A.0 + u8::try_from(i).unwrap(), + "{} breaks the contiguous band", + r.name + ); + assert_eq!( + by_index(r.index), + Some(r), + "{} resolves by position", + r.name + ); + } + // refusal at both edges — the lookup can say no + assert_eq!(by_index(FnIndex(IS_A.0 - 1)), None, "below the band"); + let past = RELATIONS.last().unwrap().index.0 + 1; + assert_eq!(by_index(FnIndex(past)), None, "past the mint"); + } + + #[test] + fn the_obo_predicate_crossing_has_one_home_and_refuses_honestly() { + use ogar_obo::Predicate as P; + for (p, want) in [ + (P::IsA, "is_a"), + (P::PartOf, "part_of"), + (P::HasPhenotype, "has_phenotype"), + (P::HasLocation, "disease_has_location"), + (P::HasQuality, "has_quality"), + ] { + assert_eq!(for_obo_predicate(p).map(|r| r.name), Some(want)); + } + // no palette row exists for these — None, never a lookalike + assert_eq!(for_obo_predicate(P::HasAnatomy), None); + assert_eq!(for_obo_predicate(P::Other), None); + } + #[test] fn ro_curies_parse_as_real_ontology_terms() { // Cross-reference: every RO: (not BFO:, which predates RO and isn't