From fb1b1e7c51fad3de58058bedc2d08157181f3d38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:54:26 +0000 Subject: [PATCH] ogar-loco: value_codebook seam + ogar-ro relation vocabulary (W-RO-1..5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the RO/ogar-loco consumer wishlist relayed from lance-graph: - W-RO-1: new ogar-ro crate — a curated RO/BFO binary-predicate palette (part_of, has_part, regulates, capable_of, …) minted as FnIndex bytes in ogar-loco's domain range, implementing Vocabulary so a relation assertion rides the same Call ABI as any other sibling vocabulary. CURIEs cross-reference real RO terms via ogar-obo::Namespace::Ro (ro_curies_parse_as_real_ontology_terms). Mints zero shared-codebook rows: the relation-body content classid sits one slot past ogar-obo's own RO term-node concept inside the existing Ontology domain. - W-RO-2: confirmed in running code, not just assertion — every minted predicate declares pushes_result = Some(false), so a relation body segments into per-assertion statements via the existing statement_bounds machinery with zero new plumbing. - W-RO-3: new ValueCodebook seam on Vocabulary (domain_value_codebook / value_codebook), threaded through FnSpec, VocabularyTable::compose, CheckedVocabulary's delegation, and conformance::check's shared-core drift detection. Default None everywhere — opt-in, zero cost for every existing vocabulary (ogar-blockly unaffected). ogar-ro is the first user: every predicate declares RELATION_TARGET_CODEBOOK, the basin-local table its subject/object operands resolve against. - W-RO-4: documented, not coded — CheckedVocabulary never required a callability marker; ogar-ro is validated exactly like ogar-blockly. - W-RO-5: recorded as an explicit, unresolved cross-repo decision in ogar-ro's module docs (LaneShape vs lance-graph-contract's CascadeShape) rather than picked unilaterally — either direction changes a dependency edge between two repos with independent release cadences. --- Cargo.toml | 1 + crates/ogar-loco/src/lib.rs | 2 +- crates/ogar-loco/src/vocabulary.rs | 72 +++++++ crates/ogar-ro/Cargo.toml | 18 ++ crates/ogar-ro/src/lib.rs | 290 +++++++++++++++++++++++++++++ 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 crates/ogar-ro/Cargo.toml create mode 100644 crates/ogar-ro/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 8071142..eb9a86c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "crates/ogar-render-typst", "crates/ogar-blockly", "crates/ogar-loco", + "crates/ogar-ro", ] [workspace.package] diff --git a/crates/ogar-loco/src/lib.rs b/crates/ogar-loco/src/lib.rs index 596e28e..f4b35f2 100644 --- a/crates/ogar-loco/src/lib.rs +++ b/crates/ogar-loco/src/lib.rs @@ -132,7 +132,7 @@ pub use program::{Program, branches_of}; pub use statements::{StatementBounds, StatementError, statement_bounds}; pub use telemetry::{FunnelTally, RefusalGate}; pub use vocabulary::conformance::CheckedVocabulary; -pub use vocabulary::{FnSpec, Vocabulary, VocabularyTable}; +pub use vocabulary::{FnSpec, ValueCodebook, Vocabulary, VocabularyTable}; // ── The function-body budget ──────────────────────────────────────────────── diff --git a/crates/ogar-loco/src/vocabulary.rs b/crates/ogar-loco/src/vocabulary.rs index f406a83..c3d863f 100644 --- a/crates/ogar-loco/src/vocabulary.rs +++ b/crates/ogar-loco/src/vocabulary.rs @@ -313,6 +313,35 @@ pub mod shared_core { } } +/// A basin-local codebook a call's value bytes are drawn from. +/// +/// Most calls' value bytes are literal numbers ([`FnIndex::NUMBER`]) or +/// function indices ([`Vocabulary::body_refs`]). A relation call is neither: +/// its operands are IDs into a **basin-local target codebook** — a codebook +/// scoped to the classid prefix the call's own body lives under, not a +/// vocabulary-wide table. `part_of(subject, object)` in one basin and +/// `part_of(subject, object)` in a sibling basin can legally resolve their +/// object byte against two different codebooks; the call itself is agnostic +/// to which. +/// +/// This is advisory metadata, not a shape constraint: declaring a codebook +/// does not change `stack_arity`/`body_refs`/`min_shape`, it tells a +/// renderer, oracle-schema generator, or fuzzer WHICH table to resolve a +/// call's operand bytes against instead of guessing "probably a literal." +/// Default `None` everywhere (shared core AND undeclared domain calls) — +/// declaring it is opt-in, paid only by a vocabulary that has basin-scoped +/// operands (W-RO-3, `ogar-ro`'s relation predicates being the first user). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValueCodebook { + /// The codebook's id, meaningful only within the basin the call's own + /// body lives under — never a global registry key. Interpreted by + /// whatever consumer owns that basin. + pub id: u8, + /// The codebook's canonical name, for legends and oracle schemas — + /// mirrors [`FnSpec::name`]'s role for the call itself. + pub name: &'static str, +} + /// A sibling codebook over the shared surface. /// /// Implementations answer for the **domain range** (bytes at/above @@ -410,6 +439,24 @@ pub trait Vocabulary { self.domain_name(f) } } + + /// The basin-local codebook `f`'s value bytes are drawn from, if any + /// (W-RO-3). Default `None` — most calls carry literals or body + /// references, not codebook ids, so the default costs nothing. + fn domain_value_codebook(&self, _f: FnIndex) -> Option { + None + } + + /// The codebook `f`'s operands resolve against — shared core is always + /// `None` (the computational core has no basin-scoped operands), domain + /// hook above the floor. + fn value_codebook(&self, f: FnIndex) -> Option { + if f.0 < DOMAIN_FLOOR { + None + } else { + self.domain_value_codebook(f) + } + } } // ── The canonical data form ───────────────────────────────────────────────── @@ -440,6 +487,9 @@ pub struct FnSpec { /// OQ-1 answered toward "the table stays the single artifact": a legend /// is then a serialization of the validated table, nothing beside it. pub name: Option<&'static str>, + /// The basin-local codebook this call's value bytes resolve against; + /// `None` = literal/body-reference operands, the common case (W-RO-3). + pub value_codebook: Option, } impl FnSpec { @@ -450,6 +500,7 @@ impl FnSpec { min_shape: LaneShape::Pairs, pushes_result: None, name: None, + value_codebook: None, }; } @@ -481,6 +532,7 @@ impl VocabularyTable { min_shape: shared_core::min_shape(f), pushes_result: shared_core::pushes_result(f), name: shared_core::name(f), + value_codebook: None, } } else { FnSpec { @@ -489,6 +541,7 @@ impl VocabularyTable { min_shape: v.min_shape(f), pushes_result: v.domain_pushes_result(f), name: v.domain_name(f), + value_codebook: v.domain_value_codebook(f), } }; } @@ -536,6 +589,13 @@ impl VocabularyTable { pub fn name(&self, f: FnIndex) -> Option<&'static str> { self.spec(f).name } + + /// The basin-local codebook `f`'s operands resolve against; `None` = no + /// codebook (literal or body-reference operands) — the common case. + #[must_use] + pub fn value_codebook(&self, f: FnIndex) -> Option { + self.spec(f).value_codebook + } } /// Mechanical conformance: what every vocabulary crate's tests must run. @@ -663,6 +723,12 @@ pub mod conformance { fn name(&self, f: FnIndex) -> Option<&'static str> { self.table.name(f) } + fn domain_value_codebook(&self, f: FnIndex) -> Option { + self.vocab.domain_value_codebook(f) + } + fn value_codebook(&self, f: FnIndex) -> Option { + self.table.value_codebook(f) + } } /// Validate a vocabulary and, on success, return the proof-carrying @@ -736,6 +802,12 @@ pub mod conformance { if v.name(f) != shared_core::name(f) { return Err(ConformanceError::SharedCoreDrift { f, what: "name" }); } + if v.value_codebook(f).is_some() { + return Err(ConformanceError::SharedCoreDrift { + f, + what: "value_codebook", + }); + } } // Everywhere: the reported minimum shape must actually hold the // call's own body references. diff --git a/crates/ogar-ro/Cargo.toml b/crates/ogar-ro/Cargo.toml new file mode 100644 index 0000000..84556e7 --- /dev/null +++ b/crates/ogar-ro/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ogar-ro" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Relation Ontology (RO) predicates as a callable ogar-loco Vocabulary — a small, curated palette of binary relation predicates (part_of, has_part, regulates, …) addressable as Call bytes in the vocabulary-agnostic ABI, so an elixir-shaped template can assert typed edges the same way a Blockly body asserts arithmetic. Cross-references real RO CURIEs via ogar-obo::Namespace::Ro; mints nothing in the shared ogar-vocab codebook." + +[features] +default = [] +serde = ["dep:serde", "ogar-loco/serde"] + +[dependencies] +ogar-loco = { path = "../ogar-loco" } +ogar-obo = { path = "../ogar-obo" } +serde = { workspace = true, optional = true } diff --git a/crates/ogar-ro/src/lib.rs b/crates/ogar-ro/src/lib.rs new file mode 100644 index 0000000..104e637 --- /dev/null +++ b/crates/ogar-ro/src/lib.rs @@ -0,0 +1,290 @@ +//! `ogar-ro` — Relation Ontology predicates as a callable [`Vocabulary`]. +//! +//! # What this is +//! +//! A curated palette of RO/BFO binary relation predicates (`part_of`, +//! `has_part`, `regulates`, `capable_of`, …), each minted as an +//! [`ogar_loco::FnIndex`] byte in the domain range. An elixir-shaped +//! template or a graph-reasoning body can therefore ASSERT a typed edge +//! `subject R object` the exact same way a Blockly body asserts arithmetic — +//! `Call = (predicate : subject, object)` — riding the identical stack-ABI, +//! [`LaneShape`] carving, and stored-node round-trip every sibling +//! vocabulary shares. One content classid, one 512-byte node, up to +//! [`LaneShape::calls_per_function`] relation calls per body. +//! +//! # Why relations are statements, not expressions (W-RO-2) +//! +//! A relation call ACTS — it asserts an edge — it does not compute a value +//! for something else to consume. So [`RelationVocabulary::domain_pushes_result`] +//! answers `Some(false)` for every minted predicate, exactly the shape +//! [`ogar_loco::statement_bounds`] already expects of a non-pushing call: a +//! statement of relation calls closes at each one, and a body of N +//! assertions segments into N statements with zero new machinery. This +//! confirms the wishlist finding directly, in running code, rather than by +//! assertion: `pushes_result` was never RO-specific plumbing to add — the +//! shared-core column already covers exactly this shape. +//! +//! # Basin-local targets (W-RO-3) +//! +//! A relation's operands are not literal numbers and not nested function +//! indices: they are IDs into a target codebook scoped to the basin the +//! call's own body lives under (medcare-gotham's `EdgeSlots`, or any sibling +//! consumer's equivalent). [`RelationVocabulary::domain_value_codebook`] +//! declares [`RELATION_TARGET_CODEBOOK`] for every minted predicate — the +//! new [`ogar_loco::vocabulary::Vocabulary::value_codebook`] seam this crate +//! is the first user of, so a renderer or oracle-schema generator knows to +//! resolve a relation call's operand bytes against the basin's own target +//! table instead of guessing "probably a literal." +//! +//! # Callability was never required (W-RO-4) +//! +//! [`CheckedVocabulary`](ogar_loco::CheckedVocabulary) proves the sharing +//! discipline and the shape invariant — nothing more. It carries no +//! "is this vocabulary actually invoked at runtime" marker, and none is +//! needed here: `ogar-ro` is validated and read exactly like `ogar-blockly` +//! is, whether or not any consumer ever executes a relation call. A +//! callability flag would be a second, redundant proof for a property the +//! type never claimed in the first place. +//! +//! # `LaneShape`/`CascadeShape` unification (W-RO-5) — NOT decided here +//! +//! `ogar_loco::LaneShape` (`Pairs`/`Triples`/`Quads`, 6×(u8:u8) / +//! 4×(u8:u8:u8) / 3×(u8:u8:u8:u8)) is bit-for-bit the same carving as +//! lance-graph-contract's `CascadeShape` (`G6D2`/`G4D3`/`G3D4`). The +//! duplication is deliberate, not an oversight — `ogar-loco` is zero-dep and +//! must stay that way (see its crate docs), while `lance-graph-contract` +//! sits in a different repo with its own release cadence. Collapsing the two +//! into one shared type would mean either OGAR depends on a lance-graph +//! crate (inverting the "OGAR is the producer, lance-graph consumes" seam) or +//! lance-graph depends on `ogar-loco` for a type it already has under its own +//! name (a needless coupling for a type that never changes shape). Neither +//! direction is this crate's call to make — it is a cross-repo dependency +//! decision, filed here as a named, unresolved wishlist item (W-RO-5) rather +//! than quietly picked. Until an operator ruling lands, the two types stay +//! independently defined and mechanically identical, exactly as they are +//! today. +//! +//! # Provenance +//! +//! Predicate CURIEs cross-reference the Relation Ontology (RO) and Basic +//! Formal Ontology (BFO) — both public, CC-BY-licensed reference +//! ontologies — via [`ogar_obo::Namespace::Ro`]. This crate mints ZERO rows +//! in the shared `ogar_vocab` codebook: the relation-body content classid +//! lives inside the already-reserved `ogar_vocab::ConceptDomain::Ontology` +//! (`0x03XX`), one slot past `ogar_obo`'s own RO term-node concept +//! (`0x0305`), the same plug-and-play posture `ogar-blockly` uses for the +//! `Blocks` domain. + +#![warn(missing_docs)] +#![forbid(unsafe_code)] + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +pub use ogar_loco::{ + BODY_BYTES, CLASSID_BYTES, CONTENT_SLOTS, Call, FnIndex, FunctionBody, LaneShape, + MAX_VALUES_PER_CALL, PAYLOAD_BYTES_PER_SLOT, SLOT_STRIDE, VALUE_SLAB_LEN, ValueCodebook, + call_in_slab, +}; + +use ogar_loco::Vocabulary; + +/// The relation-body content classid's concept id — one slot past +/// `ogar_obo::Namespace::Ro`'s term-node concept (`0x0305`) inside the +/// shared `Ontology` domain (`0x03XX`). Authoritative HERE, never minted in +/// `ogar_vocab`'s codebook (plug-and-play, mirroring `ogar_blockly::BlockConcept`). +pub const RELATION_BODY_CONCEPT_ID: u16 = 0x0306; + +/// The full V3 render classid under a consumer's app prefix — canon-high +/// `(concept << 16) | app_prefix`, the same idiom every sibling vocabulary +/// uses (`ogar_blockly::BlockConcept::render_classid`, +/// `ogar_vocab::render_classid`). +#[must_use] +pub const fn relation_body_render_classid(app_prefix: u16) -> u32 { + ((RELATION_BODY_CONCEPT_ID as u32) << 16) | (app_prefix as u32) +} + +/// The single basin-local codebook every minted predicate's operands resolve +/// against (W-RO-3). One codebook, not one per predicate: a relation body's +/// subject and object bytes are both IDs into the SAME basin-scoped target +/// table, regardless of which predicate names the edge — mirroring +/// medcare-gotham's `EdgeSlots` shape (one target space, many predicate +/// types over it). `id` is basin-local and carries no meaning outside the +/// basin that owns it; only `name` is stable across basins. +pub const RELATION_TARGET_CODEBOOK: ValueCodebook = ValueCodebook { + id: 0, + name: "relation_target", +}; + +/// One RO/BFO predicate this palette mints, paired with its real ontology +/// CURIE for cross-reference and its canonical mnemonic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RelationPredicate { + /// The minted [`FnIndex`] byte. + pub index: FnIndex, + /// The canonical mnemonic (matches [`Vocabulary::name`]'s answer). + pub name: &'static str, + /// The real ontology CURIE this predicate names — `RO:*` for Relation + /// Ontology terms, `BFO:*` for Basic Formal Ontology terms (mereology + /// predates RO and still lives in BFO upstream). + pub curie: &'static str, +} + +macro_rules! relation_palette { + ($( $slot:expr => $ident:ident, $name:literal, $curie:literal );+ $(;)?) => { + $( + #[doc = concat!("`", $name, "` (`", $curie, "`) — the minted [`FnIndex`] slot.")] + pub const $ident: FnIndex = FnIndex($slot); + )+ + + /// Every predicate this palette mints, in slot order — the + /// enumeration hook a consumer uses to inherit the full set instead + /// of hand-maintaining a parallel list (mirrors + /// `ogar_blockly::BlockConcept::ALL`). + pub const RELATIONS: &[RelationPredicate] = &[ + $( + RelationPredicate { index: $ident, name: $name, curie: $curie }, + )+ + ]; + }; +} + +relation_palette! { + 0x90 => IS_A, "is_a", "RO:0002331"; + 0x91 => PART_OF, "part_of", "BFO:0000050"; + 0x92 => HAS_PART, "has_part", "BFO:0000051"; + 0x93 => OVERLAPS, "overlaps", "RO:0002131"; + 0x94 => DEVELOPS_FROM, "develops_from", "RO:0002202"; + 0x95 => REGULATES, "regulates", "RO:0002211"; + 0x96 => NEGATIVELY_REGULATES, "negatively_regulates", "RO:0002212"; + 0x97 => POSITIVELY_REGULATES, "positively_regulates", "RO:0002213"; + 0x98 => CAPABLE_OF, "capable_of", "RO:0002215"; + 0x99 => CAPABLE_OF_PART_OF, "capable_of_part_of", "RO:0002216"; + 0x9A => IMMEDIATELY_PRECEDED_BY, "immediately_preceded_by", "RO:0002090"; + 0x9B => LOCATED_IN, "located_in", "RO:0001025"; + 0x9C => HAS_PARTICIPANT, "has_participant", "RO:0000057"; + 0x9D => DERIVES_FROM, "derives_from", "RO:0001000"; + 0x9E => HAS_PHENOTYPE, "has_phenotype", "RO:0002200"; + 0x9F => DISEASE_HAS_LOCATION, "disease_has_location", "RO:0004026"; +} + +/// The RO/BFO relation palette as an `ogar-loco` [`Vocabulary`]. +/// +/// Every minted predicate is a **binary assertion**: it pops two operands +/// (subject, object), branches to nothing (`body_refs = 0` — a relation is a +/// leaf, never a nested body), and pushes nothing (W-RO-2). Bytes above the +/// mint (`0xA0..=0xFF`) are reserved, not allocated — the same posture +/// `ogar-blockly` takes for its unminted device families: refused rather +/// than guessed, until a consumer needs the next predicate. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RelationVocabulary; + +impl RelationVocabulary { + fn minted(f: FnIndex) -> bool { + RELATIONS.iter().any(|r| r.index == f) + } +} + +impl Vocabulary for RelationVocabulary { + fn domain_stack_arity(&self, f: FnIndex) -> Option { + Self::minted(f).then_some(2) + } + + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + + fn domain_pushes_result(&self, f: FnIndex) -> Option { + // W-RO-2: a relation acts, it does not compute a value for a caller + // to consume — `Some(false)`, never `Some(true)`, for every minted + // predicate. + Self::minted(f).then_some(false) + } + + fn domain_name(&self, f: FnIndex) -> Option<&'static str> { + RELATIONS.iter().find(|r| r.index == f).map(|r| r.name) + } + + fn domain_value_codebook(&self, f: FnIndex) -> Option { + Self::minted(f).then_some(RELATION_TARGET_CODEBOOK) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ogar_loco::DOMAIN_FLOOR; + use ogar_loco::vocabulary::conformance; + + #[test] + fn the_relation_vocabulary_conforms_to_the_sharing_discipline() { + assert_eq!(conformance::check(&RelationVocabulary), Ok(())); + } + + #[test] + fn every_minted_predicate_is_a_binary_non_pushing_leaf_assertion() { + let v = RelationVocabulary; + for r in RELATIONS { + assert_eq!(v.stack_arity(r.index), Some(2), "{} arity", r.name); + assert_eq!(v.body_refs(r.index), 0, "{} body_refs", r.name); + assert!(!v.branches(r.index), "{} must not branch", r.name); + assert_eq!( + v.pushes_result(r.index), + Some(false), + "{} must not push (W-RO-2)", + r.name + ); + assert_eq!(v.name(r.index), Some(r.name)); + assert_eq!( + v.value_codebook(r.index), + Some(RELATION_TARGET_CODEBOOK), + "{} must declare the target codebook (W-RO-3)", + r.name + ); + } + } + + #[test] + fn an_unminted_domain_byte_is_refused_not_guessed() { + let v = RelationVocabulary; + let unminted = FnIndex(0xA0); + assert_eq!(v.stack_arity(unminted), None); + assert_eq!(v.pushes_result(unminted), None); + assert_eq!(v.value_codebook(unminted), None); + assert_eq!(v.name(unminted), None); + } + + #[test] + fn predicate_slots_are_distinct_and_start_at_the_domain_floor() { + let mut seen = Vec::new(); + for r in RELATIONS { + assert!(r.index.0 >= DOMAIN_FLOOR, "{} below the floor", r.name); + assert!(!seen.contains(&r.index.0), "{} duplicates a slot", r.name); + seen.push(r.index.0); + } + assert!(seen.len() >= 12, "palette shrank to {}", seen.len()); + } + + #[test] + fn ro_curies_parse_as_real_ontology_terms() { + // Cross-reference: every RO: (not BFO:, which predates RO and isn't + // in ogar_obo::Namespace) predicate CURIE here must parse as a real + // ogar_obo::TermId under Namespace::Ro — proving this palette names + // actual RO terms, not invented mnemonics. + for r in RELATIONS.iter().filter(|r| r.curie.starts_with("RO:")) { + let term = ogar_obo::TermId::parse(r.curie) + .unwrap_or_else(|| panic!("{} ({}) does not parse", r.name, r.curie)); + assert_eq!(term.namespace(), ogar_obo::Namespace::Ro, "{}", r.name); + } + } + + #[test] + fn relation_body_classid_lands_one_slot_past_the_ro_term_node_concept() { + assert_eq!(RELATION_BODY_CONCEPT_ID, 0x0306); + let id = relation_body_render_classid(0x1000); + assert_eq!(id, 0x0306_1000); + assert_eq!(id >> 16, u32::from(RELATION_BODY_CONCEPT_ID)); + assert_eq!(id & 0xFFFF, 0x1000); + } +}