From 713c8a521a5577db2b1db8bfc3b54603c31eb8da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:00:18 +0000 Subject: [PATCH] =?UTF-8?q?ogar-loco:=20VocabularyRegistry=20=E2=80=94=20p?= =?UTF-8?q?lug-and-play=20vocabularies,=20USB-shaped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plug-and-play pattern ogar-vocab and lance-graph-contract already use (classid prefix -> resolve), one level down: a classid now selects a *semantic table*, not just a domain tag, so a consumer routes stored function nodes without ever branching on which vocabulary they came from. The USB handshake, made typed: - Enumeration IS validation. `plug` accepts only a CheckedVocabulary, so a device that has not passed conformance cannot reach the port and the hub can never route to an unproven table. - The descriptor IS the classid. `resolve_classid` reads the hi u16 (concept, canon-high per D-CLASSID-CANON-HIGH-FLIP) and ignores the lo u16 — two apps with different render skins share one semantics. - What is stored is data, not a driver: registration copies the composed VocabularyTable (R4), so no trait objects, no lifetimes back into the vocabulary crate, and unplugging that crate cannot invalidate the table. - A contested concept is refused loudly (RegistryError::ConceptTaken), never last-write-wins — two crates claiming one concept must surface at boot, not at read time. Each vocabulary crate now ships its own `plug_into(&mut registry)`: ogar-blockly plugs the Blocks *content* concept (Inventory rows are registry entries, not bodies, so they carry no call vocabulary); ogar-ro plugs the relation-body concept. A consumer (blockly-rs, lance-graph) builds ONE hub at boot from whatever crates it deps. Falsified across crates in ogar-ro/tests/plug_and_play.rs: the same FnIndex resolves to a covered predicate under one classid and refuses under the other; the shared core stays byte-identical across every plugged device (anti-vacuity anchored on ADD/IF_ELSE); an unplugged concept resolves to None rather than a default; a double-plug is refused and the first device keeps its port. --- crates/ogar-blockly/src/lib.rs | 30 +++- crates/ogar-loco/src/lib.rs | 2 + crates/ogar-loco/src/registry.rs | 240 ++++++++++++++++++++++++++ crates/ogar-ro/Cargo.toml | 3 + crates/ogar-ro/src/lib.rs | 29 +++- crates/ogar-ro/tests/plug_and_play.rs | 96 +++++++++++ 6 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 crates/ogar-loco/src/registry.rs create mode 100644 crates/ogar-ro/tests/plug_and_play.rs diff --git a/crates/ogar-blockly/src/lib.rs b/crates/ogar-blockly/src/lib.rs index e2ca872..49fc605 100644 --- a/crates/ogar-blockly/src/lib.rs +++ b/crates/ogar-blockly/src/lib.rs @@ -83,7 +83,7 @@ pub use ogar_loco::{ MAX_VALUES_PER_CALL, PAYLOAD_BYTES_PER_SLOT, SLOT_STRIDE, VALUE_SLAB_LEN, call_in_slab, }; -use ogar_loco::{DOMAIN_FLOOR, Vocabulary}; +use ogar_loco::{DOMAIN_FLOOR, RegistryError, Vocabulary, VocabularyRegistry}; /// The reserved Blocks [`ConceptDomain`] every block node routes on. Live in /// `ogar_vocab` with zero shared codebook rows, so a consumer can branch on it @@ -212,6 +212,34 @@ impl Vocabulary for BlocklyVocabulary { } } +// ── Plug-and-play ─────────────────────────────────────────────────────────── + +/// Validate this palette and plug it into a consumer's +/// [`VocabularyRegistry`] under the Blocks **content** concept +/// ([`BlockConcept::Content`]) — the USB handshake for this device. +/// +/// A consumer (blockly-rs, lance-graph) builds ONE registry at boot and +/// calls each vocabulary crate's `plug_into`; every stored function node +/// then resolves through `registry.resolve_classid(node_classid)`, with no +/// consumer-side "this node must be Blockly" branch. Only the CONTENT +/// concept is plugged: [`BlockConcept::Inventory`] rows are registry +/// entries, not function bodies, so they carry no call vocabulary. +/// +/// # Errors +/// +/// [`RegistryError::ConceptTaken`] if something already claimed the Blocks +/// content concept — refused loudly rather than silently overwritten. +/// +/// # Panics +/// +/// Never in practice: [`BlocklyVocabulary`] conformance is pinned by this +/// crate's own tests, so `validate` cannot fail here. +pub fn plug_into(registry: &mut VocabularyRegistry) -> Result<(), RegistryError> { + let checked = ogar_loco::vocabulary::conformance::validate(BlocklyVocabulary) + .expect("BlocklyVocabulary conforms; pinned by this crate's tests"); + registry.plug(BlockConcept::Content.concept_id(), &checked) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ogar-loco/src/lib.rs b/crates/ogar-loco/src/lib.rs index f4b35f2..b4fde03 100644 --- a/crates/ogar-loco/src/lib.rs +++ b/crates/ogar-loco/src/lib.rs @@ -122,6 +122,7 @@ use serde::{Deserialize, Serialize}; pub mod node; pub mod pool; pub mod program; +pub mod registry; pub mod statements; pub mod telemetry; pub mod vocabulary; @@ -129,6 +130,7 @@ pub mod vocabulary; pub use node::FunctionNode; pub use pool::{Constant, ConstantPool, PoolError}; pub use program::{Program, branches_of}; +pub use registry::{RegistryError, VocabularyRegistry}; pub use statements::{StatementBounds, StatementError, statement_bounds}; pub use telemetry::{FunnelTally, RefusalGate}; pub use vocabulary::conformance::CheckedVocabulary; diff --git a/crates/ogar-loco/src/registry.rs b/crates/ogar-loco/src/registry.rs new file mode 100644 index 0000000..7d3be2a --- /dev/null +++ b/crates/ogar-loco/src/registry.rs @@ -0,0 +1,240 @@ +//! The vocabulary registry — plug-and-play routing from classid to table. +//! +//! # Plugging a USB stick (the operator frame) +//! +//! A vocabulary crate is a *device*; this registry is the *hub*. Plugging in +//! is the USB handshake, made typed: +//! +//! 1. **Enumeration = validation.** [`plug`](VocabularyRegistry::plug) only +//! accepts a [`CheckedVocabulary`] — a device that has not passed +//! [`conformance::validate`](crate::vocabulary::conformance::validate) +//! cannot even reach the port. There is no "register now, validate later" +//! path, so the hub never routes to an unproven table. +//! 2. **The descriptor = the classid.** The node's classid (canon-high: +//! concept id in the hi u16, app render prefix in the lo u16 — +//! `D-CLASSID-CANON-HIGH-FLIP`) is what a consumer reads off a stored +//! node to pick the vocabulary. Same routing move as +//! `ogar_vocab::canonical_concept_domain` — hi-byte prefix dispatch — +//! one level down, resolving to a *semantic table* instead of a domain +//! tag. +//! 3. **What is stored is data, not a driver.** Registration copies the +//! composed [`VocabularyTable`] (the R4 data-first artifact) — no trait +//! objects, no lifetimes back into the vocabulary crate, no generics in +//! the registry type. Unplugging the crate that registered it could not +//! invalidate the table: it is 256 [`FnSpec`](crate::FnSpec) rows of +//! plain `Copy` data. +//! +//! # Why the key stays opaque +//! +//! [`FunctionNode::key`](crate::FunctionNode::key) is deliberately +//! uninterpreted by this crate (see `node.rs`) — minting and key layout are +//! the canon's business. The registry therefore takes the **classid** (or +//! bare concept id), which the caller extracts from wherever its keys carry +//! it. The one canon fact this module does encode is the half-order: +//! concept HIGH, app prefix LOW. +//! +//! # Who plugs in +//! +//! Each vocabulary crate ships a `plug_into(&mut registry)` helper — +//! `ogar-blockly` plugs the Blocks content concept, `ogar-ro` plugs the +//! relation-body concept — and a consumer (blockly-rs, lance-graph) builds +//! ONE registry at boot from the crates it deps, then resolves every stored +//! function node through it. Two frontends, one hub, no hardcoded "this +//! node must be Blockly." + +use crate::vocabulary::conformance::CheckedVocabulary; +use crate::{Vocabulary, VocabularyTable}; + +/// The hub: concept id → validated [`VocabularyTable`]. +/// +/// Deliberately small and boring — a sorted-on-demand `Vec` of pairs, not a +/// hash map, because a workspace plugs in a handful of vocabularies (three +/// today), and a `Vec` keeps this module dependency-free and `const`-friendly. +#[derive(Debug, Clone, Default)] +pub struct VocabularyRegistry { + entries: Vec<(u16, VocabularyTable)>, +} + +/// Why a plug was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistryError { + /// The concept id already routes to a table. Two vocabularies claiming + /// one concept would make resolution order-dependent — refused loudly, + /// never last-write-wins. + ConceptTaken { + /// The contested concept id. + concept_id: u16, + }, +} + +impl core::fmt::Display for RegistryError { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + RegistryError::ConceptTaken { concept_id } => write!( + fmt, + "concept {concept_id:#06x} already has a registered vocabulary" + ), + } + } +} + +impl core::error::Error for RegistryError {} + +impl VocabularyRegistry { + /// An empty hub. + #[must_use] + pub const fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + /// Plug a validated vocabulary in under a concept id. + /// + /// Only a [`CheckedVocabulary`] is accepted — validation IS the + /// enumeration handshake. The composed table is copied out; the + /// wrapper (and the crate that built it) owes the registry nothing + /// afterward. + /// + /// # Errors + /// + /// [`RegistryError::ConceptTaken`] if the concept id is already routed. + pub fn plug( + &mut self, + concept_id: u16, + v: &CheckedVocabulary, + ) -> Result<(), RegistryError> { + if self.resolve_concept(concept_id).is_some() { + return Err(RegistryError::ConceptTaken { concept_id }); + } + self.entries.push((concept_id, *v.table())); + Ok(()) + } + + /// The table registered for a bare concept id, if any. + #[must_use] + pub fn resolve_concept(&self, concept_id: u16) -> Option<&VocabularyTable> { + self.entries + .iter() + .find(|(id, _)| *id == concept_id) + .map(|(_, t)| t) + } + + /// The table a full V3 classid routes to — canon-high: the concept id is + /// the classid's **hi u16**, the lo u16 is the app render prefix and + /// does not participate in vocabulary routing (skins differ per app; + /// semantics do not). + #[must_use] + pub fn resolve_classid(&self, classid: u32) -> Option<&VocabularyTable> { + self.resolve_concept((classid >> 16) as u16) + } + + /// Every plugged concept id, in plug order — the legend hook (which + /// devices are on the hub). + pub fn concepts(&self) -> impl Iterator + '_ { + self.entries.iter().map(|(id, _)| *id) + } + + /// How many vocabularies are plugged in. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the hub is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FnIndex; + use crate::vocabulary::conformance::validate; + + struct EmptyVocab; + impl Vocabulary for EmptyVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + } + + struct NamedVocab; + impl Vocabulary for NamedVocab { + fn domain_stack_arity(&self, f: FnIndex) -> Option { + (f.0 == 0x90).then_some(0) + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn domain_name(&self, f: FnIndex) -> Option<&'static str> { + (f.0 == 0x90).then_some("named_verb") + } + } + + #[test] + fn a_classid_routes_by_its_hi_u16_concept_and_ignores_the_app_prefix() { + let mut hub = VocabularyRegistry::new(); + hub.plug(0x1701, &validate(NamedVocab).unwrap()).unwrap(); + // Two DIFFERENT app prefixes, same concept: one vocabulary. The skin + // differs per app; the semantics must not. + let a = hub.resolve_classid(0x1701_1000).unwrap(); + let b = hub.resolve_classid(0x1701_BEEF).unwrap(); + assert_eq!(a.name(FnIndex(0x90)), Some("named_verb")); + assert_eq!(a, b); + // …and a concept nobody plugged resolves to nothing, not a guess. + assert_eq!(hub.resolve_classid(0x0306_1000), None); + } + + #[test] + fn two_vocabularies_on_one_hub_stay_distinct() { + let mut hub = VocabularyRegistry::new(); + hub.plug(0x1701, &validate(EmptyVocab).unwrap()).unwrap(); + hub.plug(0x0306, &validate(NamedVocab).unwrap()).unwrap(); + assert_eq!(hub.len(), 2); + // The empty vocabulary refuses 0x90; the named one covers it — + // resolution by concept keeps them apart. + assert_eq!( + hub.resolve_concept(0x1701) + .unwrap() + .stack_arity(FnIndex(0x90)), + None + ); + assert_eq!( + hub.resolve_concept(0x0306) + .unwrap() + .stack_arity(FnIndex(0x90)), + Some(0) + ); + // Both answer the shared core identically — the floor discipline + // survives registration. + assert_eq!( + hub.resolve_concept(0x1701) + .unwrap() + .stack_arity(FnIndex::ADD), + hub.resolve_concept(0x0306) + .unwrap() + .stack_arity(FnIndex::ADD), + ); + } + + #[test] + fn a_contested_concept_is_refused_not_overwritten() { + let mut hub = VocabularyRegistry::new(); + hub.plug(0x1701, &validate(NamedVocab).unwrap()).unwrap(); + assert_eq!( + hub.plug(0x1701, &validate(EmptyVocab).unwrap()), + Err(RegistryError::ConceptTaken { concept_id: 0x1701 }) + ); + // The FIRST device keeps the port: still the named table. + assert_eq!( + hub.resolve_concept(0x1701).unwrap().name(FnIndex(0x90)), + Some("named_verb") + ); + } +} diff --git a/crates/ogar-ro/Cargo.toml b/crates/ogar-ro/Cargo.toml index 84556e7..caedc02 100644 --- a/crates/ogar-ro/Cargo.toml +++ b/crates/ogar-ro/Cargo.toml @@ -16,3 +16,6 @@ serde = ["dep:serde", "ogar-loco/serde"] ogar-loco = { path = "../ogar-loco" } ogar-obo = { path = "../ogar-obo" } serde = { workspace = true, optional = true } + +[dev-dependencies] +ogar-blockly = { path = "../ogar-blockly" } diff --git a/crates/ogar-ro/src/lib.rs b/crates/ogar-ro/src/lib.rs index 104e637..86c4c0c 100644 --- a/crates/ogar-ro/src/lib.rs +++ b/crates/ogar-ro/src/lib.rs @@ -87,7 +87,7 @@ pub use ogar_loco::{ call_in_slab, }; -use ogar_loco::Vocabulary; +use ogar_loco::{RegistryError, Vocabulary, VocabularyRegistry}; /// The relation-body content classid's concept id — one slot past /// `ogar_obo::Namespace::Ro`'s term-node concept (`0x0305`) inside the @@ -211,6 +211,33 @@ impl Vocabulary for RelationVocabulary { } } +// ── Plug-and-play ─────────────────────────────────────────────────────────── + +/// Validate this palette and plug it into a consumer's +/// [`VocabularyRegistry`] under [`RELATION_BODY_CONCEPT_ID`] — the USB +/// handshake for this device, identical in shape to +/// `ogar_blockly::plug_into`. +/// +/// A consumer deps whichever vocabulary crates it needs and calls each +/// one's `plug_into` at boot; a stored relation node then resolves its +/// table through `registry.resolve_classid(classid)` with no consumer-side +/// knowledge that RO exists as a special case. +/// +/// # Errors +/// +/// [`RegistryError::ConceptTaken`] if something already claimed the +/// relation-body concept. +/// +/// # Panics +/// +/// Never in practice: [`RelationVocabulary`] conformance is pinned by this +/// crate's own tests, so `validate` cannot fail here. +pub fn plug_into(registry: &mut VocabularyRegistry) -> Result<(), RegistryError> { + let checked = ogar_loco::vocabulary::conformance::validate(RelationVocabulary) + .expect("RelationVocabulary conforms; pinned by this crate's tests"); + registry.plug(RELATION_BODY_CONCEPT_ID, &checked) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ogar-ro/tests/plug_and_play.rs b/crates/ogar-ro/tests/plug_and_play.rs new file mode 100644 index 0000000..af5af96 --- /dev/null +++ b/crates/ogar-ro/tests/plug_and_play.rs @@ -0,0 +1,96 @@ +//! Two vocabularies, one hub — the plug-and-play claim, tested across crates. +//! +//! This is the falsifier for "a consumer deps the vocabulary crates it wants +//! and routes purely by classid": it plugs `ogar-blockly` and `ogar-ro` into +//! one registry — exactly what blockly-rs / lance-graph do at boot — and +//! proves a stored node's classid alone selects the right semantic table, +//! with no consumer-side branch naming either vocabulary. + +use ogar_loco::{FnIndex, VocabularyRegistry}; + +/// The boot sequence a consumer actually writes: one hub, N `plug_into` +/// calls, nothing vocabulary-specific afterward. +fn boot() -> VocabularyRegistry { + let mut hub = VocabularyRegistry::new(); + ogar_blockly::plug_into(&mut hub).unwrap(); + ogar_ro::plug_into(&mut hub).unwrap(); + hub +} + +#[test] +fn one_hub_routes_two_vocabularies_by_classid_alone() { + let hub = boot(); + assert_eq!(hub.len(), 2); + + // Two stored nodes under DIFFERENT app prefixes — routing must ignore + // the lo u16 (render skin) and read only the hi u16 (concept). + let blockly_node = ogar_blockly::BlockConcept::Content.render_classid(0x1000); + let relation_node = ogar_ro::relation_body_render_classid(0xBEEF); + + let blocks = hub.resolve_classid(blockly_node).expect("blocks plugged"); + let relations = hub.resolve_classid(relation_node).expect("ro plugged"); + + // The RO table covers its predicates; the Blockly table refuses that + // same byte (no device family minted). Same FnIndex, two answers — + // which is the whole point of routing by classid. + let part_of = ogar_ro::PART_OF; + assert_eq!(relations.stack_arity(part_of), Some(2)); + assert_eq!(relations.name(part_of), Some("part_of")); + assert_eq!(blocks.stack_arity(part_of), None); + assert_eq!(blocks.name(part_of), None); +} + +#[test] +fn the_shared_core_is_identical_across_every_plugged_device() { + // The floor discipline must survive registration: two devices on one hub + // answer the shared computational range the same way, byte for byte. + // A drift here would mean `ADD` means two things depending on which node + // you happened to load. + let hub = boot(); + let blocks = hub + .resolve_classid(ogar_blockly::BlockConcept::Content.render_classid(0x1000)) + .unwrap(); + let relations = hub + .resolve_classid(ogar_ro::relation_body_render_classid(0x1000)) + .unwrap(); + + for b in 0..ogar_loco::DOMAIN_FLOOR { + let f = FnIndex(b); + assert_eq!(blocks.stack_arity(f), relations.stack_arity(f), "{f:?}"); + assert_eq!(blocks.body_refs(f), relations.body_refs(f), "{f:?}"); + assert_eq!(blocks.pushes_result(f), relations.pushes_result(f), "{f:?}"); + assert_eq!(blocks.name(f), relations.name(f), "{f:?}"); + } + // Anti-vacuity: the shared range must actually be covered somewhere, or + // "identical" is trivially true of two empty tables. + assert_eq!(blocks.stack_arity(FnIndex::ADD), Some(2)); + assert_eq!(blocks.body_refs(FnIndex::IF_ELSE), 2); +} + +#[test] +fn an_unplugged_concept_resolves_to_nothing_rather_than_a_default() { + // The fail-closed half: a consumer that forgot to dep a vocabulary gets + // `None` and can refuse, never a silently-wrong table. A hub that + // answered *something* for every classid would carry no information. + let hub = boot(); + assert!(hub.resolve_classid(0x0999_1000).is_none()); + // …and the inventory concept is deliberately NOT plugged: registry rows + // are not function bodies, so they carry no call vocabulary. + let inventory = ogar_blockly::BlockConcept::Inventory.render_classid(0x1000); + assert!(hub.resolve_classid(inventory).is_none()); +} + +#[test] +fn plugging_the_same_device_twice_is_refused() { + // Idempotence is NOT the contract — a double-plug means two crates think + // they own one concept, which must surface at boot, not at read time. + let mut hub = boot(); + assert!(ogar_ro::plug_into(&mut hub).is_err()); + assert!(ogar_blockly::plug_into(&mut hub).is_err()); + // …and the first device kept its port. + assert_eq!(hub.len(), 2); + let relations = hub + .resolve_classid(ogar_ro::relation_body_render_classid(0x1000)) + .unwrap(); + assert_eq!(relations.name(ogar_ro::PART_OF), Some("part_of")); +}