Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions crates/ogar-obo/src/edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
162 changes: 162 additions & 0 deletions crates/ogar-obo/src/layout.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
}
67 changes: 62 additions & 5 deletions crates/ogar-obo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

pub mod crosswalk;
pub mod edges;
pub mod layout;
#[cfg(feature = "rdf")]
pub mod rdf;
pub mod reason;
Expand All @@ -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)]
Expand Down Expand Up @@ -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<Namespace> {
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).
Expand All @@ -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<Predicate> {
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 {
Expand Down
Loading
Loading