diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 038ef7d8..0e3b6e40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,3 +77,10 @@ jobs: # "reference backend" promise, now real. - name: cargo test -p ogar-knowable-from --features vart-backend run: cargo test -p ogar-knowable-from --features vart-backend + # Exercise the `ontology-cache` feature on ogar-action-handler — pulls + # in lance-graph-ontology by git to load OGIT into the Ontology cache + # and compare its names with the OGIT tables. Off by default so + # path-based consumers (rs-graph-llm) stay lean; same crate-scoped + # pattern as the other feature steps. + - name: cargo test -p ogar-action-handler --features ontology-cache + run: cargo test -p ogar-action-handler --features ontology-cache diff --git a/crates/ogar-action-handler/Cargo.toml b/crates/ogar-action-handler/Cargo.toml index 17e196e7..f9f3ed14 100644 --- a/crates/ogar-action-handler/Cargo.toml +++ b/crates/ogar-action-handler/Cargo.toml @@ -6,10 +6,15 @@ license.workspace = true repository.workspace = true authors.workspace = true rust-version.workspace = true -description = "OGAR-native HIRO ActionHandler runtime — CapabilityExecutor impls (the B1 executor seam) over the action-ws protocol core in ogar-from-schema. The reference NativeCommandExecutor runs a capability's command via a local process; SSH/REST targets follow the same trait. rs-graph-llm's graph-flow-action provides the production executors." +description = "OGAR-native HIRO ActionHandler runtime — CapabilityExecutor impls (the B1 executor seam) over the action-ws protocol core in ogar-from-schema. The reference NativeCommandExecutor runs a capability's command via a local process; SSH/REST targets follow the same trait. rs-graph-llm's graph-flow-action provides the production executors. Also holds the OGIT tables HIRO validates against and, behind `ontology-cache`, the Ontology cache loader." [features] default = [] +# The Ontology cache: lance-graph-ontology's OntologyRegistry, loaded from an +# OGIT tree (`ogit::load_registry`). Off by default, because it brings +# lance-graph by git, which path-based consumers such as rs-graph-llm's +# graph-flow-action-ogar do not need. +ontology-cache = ["dep:lance-graph-ontology"] [dependencies] ogar-from-schema = { path = "../ogar-from-schema", features = ["serde"] } @@ -19,6 +24,7 @@ ogar-vocab = { path = "../ogar-vocab" } # (`ogar-from-schema`) stays parser-free — it defines the `Deserialize` DTOs + # the pure lift; this crate does the `from_str`. (B2-lift.) serde_json = "1.0" +lance-graph-ontology = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main", optional = true } [dev-dependencies] # Execution probe only: drives the executor from a *lifted* ActionDef diff --git a/crates/ogar-action-handler/examples/ogit_tables.rs b/crates/ogar-action-handler/examples/ogit_tables.rs new file mode 100644 index 00000000..f768b499 --- /dev/null +++ b/crates/ogar-action-handler/examples/ogit_tables.rs @@ -0,0 +1,40 @@ +//! `ogit_tables []`: loads the OGIT tables and prints their sizes +//! and how long the load took. The root defaults to `OGIT_FORK_PATH`, then to +//! `../OGIT`. + +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Instant; + +use ogar_action_handler::ogit::{OgitTables, Table}; + +fn main() -> ExitCode { + let root = std::env::args() + .nth(1) + .map(PathBuf::from) + .or_else(|| std::env::var_os("OGIT_FORK_PATH").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("../OGIT")); + let start = Instant::now(); + let tables = match OgitTables::load(&root) { + Ok(t) => t, + Err(e) => { + eprintln!("{}: {e}", root.display()); + return ExitCode::FAILURE; + } + }; + let elapsed = start.elapsed(); + for table in [ + Table::Type, + Table::Attribute, + Table::TypeAttr, + Table::Verb, + Table::Allowed, + ] { + println!("{table:?}\t{} rows", tables.rows(table)); + } + println!("domains\t{}", tables.domains().len()); + println!("rejected files\t{}", tables.rejected().len()); + println!("unresolved references\t{}", tables.unresolved().len()); + println!("load\t{:.1} ms", elapsed.as_secs_f64() * 1000.0); + ExitCode::SUCCESS +} diff --git a/crates/ogar-action-handler/src/lib.rs b/crates/ogar-action-handler/src/lib.rs index 7f0e7861..40f3830f 100644 --- a/crates/ogar-action-handler/src/lib.rs +++ b/crates/ogar-action-handler/src/lib.rs @@ -34,10 +34,16 @@ //! let result = reaction.result.unwrap(); //! assert!(result.result.contains("\"output\":\"hi\"")); //! ``` +//! +//! [`ogit`] holds the OGIT tables HIRO validates against, read once from an +//! OGIT tree, and, behind the `ontology-cache` feature, the Ontology cache +//! loader. #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod ogit; + use std::process::Command; use std::collections::BTreeMap; diff --git a/crates/ogar-action-handler/src/ogit.rs b/crates/ogar-action-handler/src/ogit.rs new file mode 100644 index 00000000..770a9307 --- /dev/null +++ b/crates/ogar-action-handler/src/ogit.rs @@ -0,0 +1,661 @@ +//! The OGIT tables HIRO validates against, and the Ontology cache. +//! +//! [`OgitTables`] reads an OGIT tree once, through OGAR's own reader +//! ([`parse_file`], [`parse_verb`]), into five tables. Every row is addressed +//! by a `u16`, and every column is a `u32` lane lent as a slice +//! ([`OgitTables::lane`]), so a mask executor can read it without a copy. +//! +//! | Table | One row per | Lanes | Row order | +//! |---|---|---|---| +//! | [`Table::Type`] | entity type, SGO core included | domain | domain, name | +//! | [`Table::Attribute`] | declared attribute | domain | domain, name | +//! | [`Table::TypeAttr`] | attribute a type declares | type, attr, flag | type, flag, TTL list position | +//! | [`Table::Verb`] | verb | domain | domain, name | +//! | [`Table::Allowed`] | `ogit:allowed` tuple | src, verb, dst | src, verb, dst | +//! +//! - **Ids are row ordinals**, so the same OGIT tree always gives the same ids. +//! - **A table past 65,536 rows is refused** ([`LoadError::TooManyRows`]) +//! rather than given a wider index. +//! - **Names stay on this side.** The tables keep each table's names for +//! lookup; no lane holds a name. +//! - **Nothing is dropped silently.** Files the reader rejects, names declared +//! twice and references to undeclared names are reported +//! ([`OgitTables::rejected`], [`OgitTables::duplicates`], +//! [`OgitTables::unresolved`]). +//! +//! With the `ontology-cache` feature, `load_registry` is the Ontology cache +//! itself: lance-graph-ontology's `OntologyRegistry`, loaded from the same tree +//! and used as it is. It holds identity only, and only for names with a +//! domain; these tables hold the rest. The plan is bardioc's +//! `ONTOLOGY_CACHE_INTEGRATION_PLAN.md`. + +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use ogar_from_schema::sgo::parse_verb; +use ogar_from_schema::ttl::parse_file; +use ogar_from_schema::{EntityDecl, TtlDeclaration}; + +/// The most rows a `u16` row address can reach. +pub const MAX_ROWS: usize = 1 << 16; + +/// One of the five tables. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Table { + /// Entity types, the SGO core included. + Type, + /// Declared attributes. + Attribute, + /// The attributes each type declares. + TypeAttr, + /// Verbs. + Verb, + /// `ogit:allowed` tuples. + Allowed, +} + +/// A column, lent as `&[u32]` by [`OgitTables::lane`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Lane { + /// [`Table::Type`]: the type's domain. + TypeDomain, + /// [`Table::Attribute`]: the attribute's domain. + AttributeDomain, + /// [`Table::Verb`]: the verb's domain. + VerbDomain, + /// [`Table::TypeAttr`]: the declaring type. + TypeAttrType, + /// [`Table::TypeAttr`]: the declared attribute. + TypeAttrAttr, + /// [`Table::TypeAttr`]: the [`Flag`]. + TypeAttrFlag, + /// [`Table::Allowed`]: the source type. + AllowedSrc, + /// [`Table::Allowed`]: the verb. + AllowedVerb, + /// [`Table::Allowed`]: the target type. + AllowedDst, +} + +impl Lane { + /// The table the lane belongs to. + #[must_use] + pub fn table(self) -> Table { + match self { + Lane::TypeDomain => Table::Type, + Lane::AttributeDomain => Table::Attribute, + Lane::VerbDomain => Table::Verb, + Lane::TypeAttrType | Lane::TypeAttrAttr | Lane::TypeAttrFlag => Table::TypeAttr, + Lane::AllowedSrc | Lane::AllowedVerb | Lane::AllowedDst => Table::Allowed, + } + } +} + +/// How a type declares an attribute: the value in [`Lane::TypeAttrFlag`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Flag { + /// `ogit:mandatory-attributes`. + Mandatory = 0, + /// `ogit:optional-attributes`. + Optional = 1, + /// `ogit:indexed-attributes`. + Indexed = 2, +} + +/// A row of [`Table::Type`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TypeId(pub u16); + +/// A row of [`Table::Attribute`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AttrId(pub u16); + +/// A row of [`Table::Verb`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VerbId(pub u16); + +/// A reference a type makes to a name the tree does not declare. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Unresolved { + /// The table the reference would have been a row of. + pub table: Table, + /// The type that makes it. + pub from: String, + /// The name, as written. + pub name: String, +} + +/// Why [`OgitTables::load`] failed. +#[derive(Debug)] +pub enum LoadError { + /// The tree could not be read. + Io(io::Error), + /// A table would need more rows than a `u16` can address. + TooManyRows { + /// The table. + table: Table, + /// The rows it would need. + rows: usize, + }, +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LoadError::Io(e) => write!(f, "the OGIT tree does not read: {e}"), + LoadError::TooManyRows { table, rows } => write!( + f, + "{table:?} would need {rows} rows, past the {MAX_ROWS} a u16 address reaches" + ), + } + } +} + +impl std::error::Error for LoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + LoadError::Io(e) => Some(e), + LoadError::TooManyRows { .. } => None, + } + } +} + +impl From for LoadError { + fn from(e: io::Error) -> Self { + LoadError::Io(e) + } +} + +/// One table's names, in row order, and the one index over them: the rows +/// sorted by name, a permutation. +#[derive(Debug, Default, PartialEq, Eq)] +struct Names { + names: Vec, + by_name: Vec, +} + +impl Names { + fn new(names: Vec) -> Self { + let mut by_name: Vec = (0..names.len()).map(row).collect(); + by_name.sort_by(|&a, &b| names[usize::from(a)].cmp(&names[usize::from(b)])); + Self { names, by_name } + } + + fn find(&self, name: &str) -> Option { + self.by_name + .binary_search_by(|&r| self.names[usize::from(r)].as_str().cmp(name)) + .ok() + .map(|i| self.by_name[i]) + } + + fn get(&self, r: u16) -> Option<&str> { + self.names.get(usize::from(r)).map(String::as_str) + } +} + +/// The five OGIT tables, read once from an OGIT tree. +#[derive(Debug, PartialEq, Eq)] +pub struct OgitTables { + domains: Vec, + types: Names, + attributes: Names, + verbs: Names, + type_domain: Vec, + attribute_domain: Vec, + verb_domain: Vec, + type_attr_type: Vec, + type_attr_attr: Vec, + type_attr_flag: Vec, + allowed_src: Vec, + allowed_verb: Vec, + allowed_dst: Vec, + rejected: Vec, + duplicates: Vec, + unresolved: Vec, +} + +impl OgitTables { + /// Reads the OGIT tree at `root`, the directory holding `NTO/` and `SGO/`. + /// + /// A file declares what its directory says: `entities/`, `verbs/` or + /// `attributes/`, in any letter case. Other `.ttl` files are not read. + /// + /// # Errors + /// [`LoadError::Io`] if the tree does not read, and + /// [`LoadError::TooManyRows`] if a table would pass [`MAX_ROWS`]. + pub fn load(root: &Path) -> Result { + let mut entities: Vec = Vec::new(); + let mut attributes: Vec = Vec::new(); + let mut verbs: Vec = Vec::new(); + let mut rejected = Vec::new(); + for path in ttl_files(root)? { + let Some(kind) = kind_of(&path) else { + continue; + }; + let src = fs::read_to_string(&path)?; + let accepted = match kind { + Kind::Verb => match parse_verb(&src) { + Some(v) => { + verbs.push(v.curie); + true + } + None => false, + }, + Kind::Entity | Kind::Attribute => match (kind, parse_file(&src)) { + (Kind::Entity, Some(TtlDeclaration::Entity(e))) => { + entities.push(e); + true + } + (Kind::Attribute, Some(TtlDeclaration::DatatypeAttribute(a))) => { + attributes.push(a.curie); + true + } + _ => false, + }, + }; + if !accepted { + rejected.push(relative(root, &path)); + } + } + + // Sorted by (domain, name). The sort is stable, so a name declared + // twice keeps the first file in path order. + let mut duplicates = Vec::new(); + entities.sort_by(|a, b| order(&a.curie).cmp(&order(&b.curie))); + entities.dedup_by(|later, kept| { + let twice = later.curie == kept.curie; + if twice { + duplicates.push(later.curie.clone()); + } + twice + }); + let attributes = sorted_unique(attributes, &mut duplicates); + let verbs = sorted_unique(verbs, &mut duplicates); + check(Table::Type, entities.len())?; + check(Table::Attribute, attributes.len())?; + check(Table::Verb, verbs.len())?; + + let domains: Vec = entities + .iter() + .map(|e| e.curie.as_str()) + .chain(attributes.iter().map(String::as_str)) + .chain(verbs.iter().map(String::as_str)) + .map(domain_of) + .collect::>() + .into_iter() + .map(str::to_owned) + .collect(); + let type_domain = domain_lane(&domains, entities.iter().map(|e| e.curie.as_str())); + let attribute_domain = domain_lane(&domains, attributes.iter().map(String::as_str)); + let verb_domain = domain_lane(&domains, verbs.iter().map(String::as_str)); + let types = Names::new(entities.iter().map(|e| e.curie.clone()).collect()); + let attributes = Names::new(attributes); + let verbs = Names::new(verbs); + + let mut unresolved = Vec::new(); + let (mut type_attr_type, mut type_attr_attr, mut type_attr_flag) = + (Vec::new(), Vec::new(), Vec::new()); + let mut allowed: Vec<(u32, u32, u32)> = Vec::new(); + for (t, e) in entities.iter().enumerate() { + let t = u32::from(row(t)); + let lists = [ + (Flag::Mandatory, &e.mandatory_attributes), + (Flag::Optional, &e.optional_attributes), + (Flag::Indexed, &e.indexed_attributes), + ]; + for (flag, list) in lists { + for name in list { + match attributes.find(name) { + Some(a) => { + type_attr_type.push(t); + type_attr_attr.push(u32::from(a)); + type_attr_flag.push(flag as u32); + } + None => unresolved.push(Unresolved { + table: Table::TypeAttr, + from: e.curie.clone(), + name: name.clone(), + }), + } + } + } + for (verb, target) in &e.allowed { + let v = verbs.find(verb); + let d = types.find(target); + for (found, name) in [(v.is_some(), verb), (d.is_some(), target)] { + if !found { + unresolved.push(Unresolved { + table: Table::Allowed, + from: e.curie.clone(), + name: name.clone(), + }); + } + } + if let (Some(v), Some(d)) = (v, d) { + allowed.push((t, u32::from(v), u32::from(d))); + } + } + } + allowed.sort_unstable(); + allowed.dedup(); + check(Table::TypeAttr, type_attr_type.len())?; + check(Table::Allowed, allowed.len())?; + + Ok(OgitTables { + domains, + types, + attributes, + verbs, + type_domain, + attribute_domain, + verb_domain, + type_attr_type, + type_attr_attr, + type_attr_flag, + allowed_src: allowed.iter().map(|a| a.0).collect(), + allowed_verb: allowed.iter().map(|a| a.1).collect(), + allowed_dst: allowed.iter().map(|a| a.2).collect(), + rejected, + duplicates, + unresolved, + }) + } + + /// How many rows `table` has. + #[must_use] + pub fn rows(&self, table: Table) -> usize { + match table { + Table::Type => self.types.names.len(), + Table::Attribute => self.attributes.names.len(), + Table::TypeAttr => self.type_attr_type.len(), + Table::Verb => self.verbs.names.len(), + Table::Allowed => self.allowed_src.len(), + } + } + + /// A column, lent without a copy: one value per row of its table. + #[must_use] + pub fn lane(&self, lane: Lane) -> &[u32] { + match lane { + Lane::TypeDomain => &self.type_domain, + Lane::AttributeDomain => &self.attribute_domain, + Lane::VerbDomain => &self.verb_domain, + Lane::TypeAttrType => &self.type_attr_type, + Lane::TypeAttrAttr => &self.type_attr_attr, + Lane::TypeAttrFlag => &self.type_attr_flag, + Lane::AllowedSrc => &self.allowed_src, + Lane::AllowedVerb => &self.allowed_verb, + Lane::AllowedDst => &self.allowed_dst, + } + } + + /// The domain names, sorted; a domain lane holds an index into this. The + /// SGO core is the empty name. + #[must_use] + pub fn domains(&self) -> &[String] { + &self.domains + } + + /// A domain's index (`MARS`, or `""` for the SGO core). + #[must_use] + pub fn domain_id(&self, name: &str) -> Option { + let i = self + .domains + .binary_search_by(|d| d.as_str().cmp(name)) + .ok()?; + u32::try_from(i).ok() + } + + /// A type by its CURIE (`ogit:Person`, `ogit.MARS:Machine`). + #[must_use] + pub fn type_id(&self, curie: &str) -> Option { + self.types.find(curie).map(TypeId) + } + + /// An attribute by its CURIE (`ogit:name`). + #[must_use] + pub fn attribute_id(&self, curie: &str) -> Option { + self.attributes.find(curie).map(AttrId) + } + + /// A verb by its CURIE (`ogit:dependsOn`). + #[must_use] + pub fn verb_id(&self, curie: &str) -> Option { + self.verbs.find(curie).map(VerbId) + } + + /// A type's CURIE. + #[must_use] + pub fn type_name(&self, id: TypeId) -> Option<&str> { + self.types.get(id.0) + } + + /// An attribute's CURIE. + #[must_use] + pub fn attribute_name(&self, id: AttrId) -> Option<&str> { + self.attributes.get(id.0) + } + + /// A verb's CURIE. + #[must_use] + pub fn verb_name(&self, id: VerbId) -> Option<&str> { + self.verbs.get(id.0) + } + + /// Files the reader did not accept, as paths inside the tree. + #[must_use] + pub fn rejected(&self) -> &[String] { + &self.rejected + } + + /// Names declared by more than one file; each keeps its first file. + #[must_use] + pub fn duplicates(&self) -> &[String] { + &self.duplicates + } + + /// References to names the tree does not declare. They have no row. + #[must_use] + pub fn unresolved(&self) -> &[Unresolved] { + &self.unresolved + } +} + +/// The Ontology cache: lance-graph-ontology's `OntologyRegistry`, used as it +/// is. +#[cfg(feature = "ontology-cache")] +pub use lance_graph_ontology::OntologyRegistry; + +/// Loads the OGIT tree at `root` into a new in-memory registry: the Ontology +/// cache. +/// +/// It keeps only names with a domain (`ogit.:`), so the SGO core +/// is not in it, and it keeps identity without attribute lists or +/// `ogit:allowed` tuples. [`OgitTables`] holds those. +/// +/// # Errors +/// If the tree cannot be read, or yields nothing to load. +#[cfg(feature = "ontology-cache")] +pub fn load_registry(root: &Path) -> Result { + let registry = OntologyRegistry::new_in_memory(); + registry.hydrate_once_sync(root, &[])?; + Ok(registry) +} + +/// Every entity name `registry` holds, sorted and without repeats. +#[cfg(feature = "ontology-cache")] +#[must_use] +pub fn entity_curies(registry: &OntologyRegistry) -> Vec { + use lance_graph_ontology::namespace::SchemaKind; + let mut out: Vec = registry + .namespace_names() + .iter() + .flat_map(|ns| registry.enumerate(ns)) + .filter(|r| r.kind == SchemaKind::Entity) + .map(|r| r.ogit_uri.into_string()) + .collect(); + out.sort(); + out.dedup(); + out +} + +/// What a file's directory says it declares. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Entity, + Verb, + Attribute, +} + +fn kind_of(path: &Path) -> Option { + let dir = path.parent()?.file_name()?.to_str()?; + [ + ("entities", Kind::Entity), + ("verbs", Kind::Verb), + ("attributes", Kind::Attribute), + ] + .into_iter() + .find(|(name, _)| dir.eq_ignore_ascii_case(name)) + .map(|(_, kind)| kind) +} + +/// Every `.ttl` file under `root`, sorted, skipping hidden directories. +fn ttl_files(root: &Path) -> io::Result> { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let path = entry?.path(); + let hidden = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with('.')); + if hidden { + continue; + } + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "ttl") { + out.push(path); + } + } + } + out.sort(); + Ok(out) +} + +fn relative(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +/// The domain of a CURIE: `MARS` for `ogit.MARS:Machine`, empty for the SGO +/// core (`ogit:Person`). +fn domain_of(curie: &str) -> &str { + curie + .strip_prefix("ogit.") + .and_then(|rest| rest.split_once(':')) + .map_or("", |(domain, _)| domain) +} + +/// The row order of the type, attribute and verb tables. +fn order(curie: &str) -> (&str, &str) { + (domain_of(curie), curie) +} + +/// Each name's domain, as an index into the sorted `domains`. +fn domain_lane<'a>(domains: &[String], names: impl Iterator) -> Vec { + names + .map(|n| { + let i = domains + .binary_search_by(|d| d.as_str().cmp(domain_of(n))) + .expect("every domain was collected"); + u32::try_from(i).expect("fewer domains than names") + }) + .collect() +} + +/// `names` sorted by [`order`], each kept once; repeats go to `duplicates`. +fn sorted_unique(mut names: Vec, duplicates: &mut Vec) -> Vec { + names.sort_by(|a, b| order(a).cmp(&order(b))); + names.dedup_by(|later, kept| { + let twice = later == kept; + if twice { + duplicates.push(later.clone()); + } + twice + }); + names +} + +fn check(table: Table, rows: usize) -> Result<(), LoadError> { + if rows > MAX_ROWS { + Err(LoadError::TooManyRows { table, rows }) + } else { + Ok(()) + } +} + +/// A row index as its `u16` address. Tables are checked against +/// [`MAX_ROWS`] before any row is addressed. +fn row(i: usize) -> u16 { + u16::try_from(i).expect("rows are checked against MAX_ROWS first") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_table_past_the_u16_address_is_refused() { + assert!(check(Table::Type, MAX_ROWS).is_ok()); + let err = check(Table::TypeAttr, MAX_ROWS + 1).unwrap_err(); + assert!(matches!( + err, + LoadError::TooManyRows { + table: Table::TypeAttr, + rows + } if rows == MAX_ROWS + 1 + )); + assert_eq!(row(MAX_ROWS - 1), u16::MAX); + } + + #[test] + fn domains_come_from_the_curie() { + assert_eq!(domain_of("ogit.MARS:Machine"), "MARS"); + assert_eq!(domain_of("ogit.MARS.Application:class"), "MARS.Application"); + assert_eq!(domain_of("ogit:Person"), ""); + assert_eq!(domain_of("not-a-curie"), ""); + } + + #[test] + fn directories_match_in_any_case() { + assert_eq!( + kind_of(Path::new("NTO/Credit/Entities/Contract.ttl")), + Some(Kind::Entity) + ); + assert_eq!( + kind_of(Path::new("SGO/sgo/verbs/dependsOn.ttl")), + Some(Kind::Verb) + ); + assert_eq!( + kind_of(Path::new("NTO/MARS/attributes/x.ttl")), + Some(Kind::Attribute) + ); + assert_eq!(kind_of(Path::new("ogit.ttl")), None); + } + + #[test] + fn names_are_found_through_the_sorted_index() { + let n = Names::new(vec!["ogit.B:x".into(), "ogit:a".into(), "ogit.A:y".into()]); + assert_eq!(n.find("ogit:a"), Some(1)); + assert_eq!(n.find("ogit.A:y"), Some(2)); + assert_eq!(n.find("ogit:zzz"), None); + assert_eq!(n.get(0), Some("ogit.B:x")); + } +} diff --git a/crates/ogar-action-handler/tests/ogit_tables.rs b/crates/ogar-action-handler/tests/ogit_tables.rs new file mode 100644 index 00000000..664e9643 --- /dev/null +++ b/crates/ogar-action-handler/tests/ogit_tables.rs @@ -0,0 +1,203 @@ +//! The OGIT tables over the floating AdaWorldAPI/OGIT checkout. + +#[allow(dead_code)] +#[path = "../../ogar-from-schema/src/ogit_checkout.rs"] +mod ogit_checkout; + +use std::fmt::Debug; +use std::sync::OnceLock; + +use ogar_action_handler::ogit::{AttrId, Flag, Lane, OgitTables, Table, TypeId, VerbId}; +use ogar_from_schema::TtlDeclaration; +use ogar_from_schema::ttl::parse_file; + +fn tables() -> &'static OgitTables { + static TABLES: OnceLock = OnceLock::new(); + TABLES + .get_or_init(|| OgitTables::load(&ogit_checkout::root()).expect("the OGIT checkout loads")) +} + +fn id(i: usize) -> u16 { + u16::try_from(i).expect("a u16 row") +} + +/// Files OGAR's reader is known to reject; Bardioc-rs's codebook tracks the +/// same set. +const KNOWN_REJECTED: [&str; 6] = [ + "NTO/CustomerSupport/", + "NTO/Mobile/", + "NTO/WorkOrder/verbs/", + "SGO/sgo/attributes/public.ttl", + "SGO/sgo/attributes/source.ttl", + "SGO/sgo/attributes/timeZone.ttl", +]; + +fn strictly_increasing(what: &str, rows: &[T]) { + if let Some(w) = rows.windows(2).find(|w| w[0] >= w[1]) { + panic!("{what} out of order: {:?} then {:?}", w[0], w[1]); + } +} + +#[test] +fn the_tables_hold_the_hiro_types_and_the_sgo_core() { + let t = tables(); + for curie in [ + "ogit:Person", + "ogit.Automation:ActionHandler", + "ogit.Automation:KnowledgeItem", + "ogit.Automation:MARSNodeTemplate", + "ogit.MARS:Application", + "ogit.MARS:Machine", + ] { + let ty = t + .type_id(curie) + .unwrap_or_else(|| panic!("{curie} is not a type")); + assert_eq!(t.type_name(ty), Some(curie)); + } + // The SGO core that lance-graph-ontology skips: a verb and an attribute + // without a domain. + assert!(t.verb_id("ogit:dependsOn").is_some()); + assert!(t.attribute_id("ogit:name").is_some()); + assert_eq!(t.domain_id(""), Some(0), "the SGO core sorts first"); +} + +#[test] +fn credit_is_read_although_its_directories_are_capitalized() { + assert!(tables().type_id("ogit.Credit:Contract").is_some()); +} + +#[test] +fn the_reader_rejects_nothing_beyond_the_known_files() { + let t = tables(); + let fresh: Vec<&String> = t + .rejected() + .iter() + .filter(|f| !KNOWN_REJECTED.iter().any(|known| f.starts_with(known))) + .collect(); + assert!( + fresh.is_empty(), + "OGAR's reader rejects new files: {fresh:?}" + ); + assert!(t.duplicates().is_empty(), "{:?}", t.duplicates()); +} + +#[test] +fn rows_follow_the_documented_order() { + let t = tables(); + let types: Vec<(u32, &str)> = (0..t.rows(Table::Type)) + .map(|i| { + ( + t.lane(Lane::TypeDomain)[i], + t.type_name(TypeId(id(i))).unwrap(), + ) + }) + .collect(); + strictly_increasing("types", &types); + let attributes: Vec<(u32, &str)> = (0..t.rows(Table::Attribute)) + .map(|i| { + let name = t.attribute_name(AttrId(id(i))).unwrap(); + (t.lane(Lane::AttributeDomain)[i], name) + }) + .collect(); + strictly_increasing("attributes", &attributes); + let verbs: Vec<(u32, &str)> = (0..t.rows(Table::Verb)) + .map(|i| { + ( + t.lane(Lane::VerbDomain)[i], + t.verb_name(VerbId(id(i))).unwrap(), + ) + }) + .collect(); + strictly_increasing("verbs", &verbs); + + let (ty, flag) = (t.lane(Lane::TypeAttrType), t.lane(Lane::TypeAttrFlag)); + assert!( + (1..ty.len()).all(|i| (ty[i - 1], flag[i - 1]) <= (ty[i], flag[i])), + "type_attr is not ordered by type, then flag" + ); + let (src, verb, dst) = ( + t.lane(Lane::AllowedSrc), + t.lane(Lane::AllowedVerb), + t.lane(Lane::AllowedDst), + ); + let allowed: Vec<(u32, u32, u32)> = (0..src.len()).map(|i| (src[i], verb[i], dst[i])).collect(); + strictly_increasing("allowed", &allowed); + + // Every id points at a row of its table, and every domain at a domain. + let rows = |table| u32::try_from(t.rows(table)).unwrap(); + for (lane, table) in [ + (Lane::TypeAttrType, Table::Type), + (Lane::TypeAttrAttr, Table::Attribute), + (Lane::AllowedSrc, Table::Type), + (Lane::AllowedVerb, Table::Verb), + (Lane::AllowedDst, Table::Type), + ] { + assert!(t.lane(lane).iter().all(|&v| v < rows(table)), "{lane:?}"); + } + let domains = u32::try_from(t.domains().len()).unwrap(); + for lane in [Lane::TypeDomain, Lane::AttributeDomain, Lane::VerbDomain] { + assert!(t.lane(lane).iter().all(|&d| d < domains), "{lane:?}"); + } +} + +#[test] +fn type_attr_keeps_each_types_lists_in_order() { + let t = tables(); + let (ty, attr, flag) = ( + t.lane(Lane::TypeAttrType), + t.lane(Lane::TypeAttrAttr), + t.lane(Lane::TypeAttrFlag), + ); + for (curie, file) in [ + ("ogit:Person", "SGO/sgo/entities/Person.ttl"), + ("ogit.MARS:Machine", "NTO/MARS/entities/Machine.ttl"), + ( + "ogit.Automation:KnowledgeItem", + "NTO/Automation/entities/KnowledgeItem.ttl", + ), + ] { + let Some(TtlDeclaration::Entity(e)) = parse_file(&ogit_checkout::read(file)) else { + panic!("{file} does not read as an entity"); + }; + let expected: Vec<(u32, &str)> = [ + (Flag::Mandatory, &e.mandatory_attributes), + (Flag::Optional, &e.optional_attributes), + (Flag::Indexed, &e.indexed_attributes), + ] + .into_iter() + .flat_map(|(f, list)| list.iter().map(move |a| (f as u32, a.as_str()))) + .filter(|(_, a)| t.attribute_id(a).is_some()) + .collect(); + let row = u32::from(t.type_id(curie).unwrap().0); + let actual: Vec<(u32, &str)> = (0..ty.len()) + .filter(|&i| ty[i] == row) + .map(|i| { + let a = AttrId(u16::try_from(attr[i]).unwrap()); + (flag[i], t.attribute_name(a).unwrap()) + }) + .collect(); + assert_eq!(actual, expected, "{curie}"); + } +} + +#[test] +fn the_mars_backbone_is_an_allowed_edge() { + let t = tables(); + let want = ( + u32::from(t.type_id("ogit.MARS:Application").unwrap().0), + u32::from(t.verb_id("ogit:dependsOn").unwrap().0), + u32::from(t.type_id("ogit.MARS:Resource").unwrap().0), + ); + let (src, verb, dst) = ( + t.lane(Lane::AllowedSrc), + t.lane(Lane::AllowedVerb), + t.lane(Lane::AllowedDst), + ); + assert!((0..src.len()).any(|i| (src[i], verb[i], dst[i]) == want)); +} + +#[test] +fn two_loads_of_one_tree_are_identical() { + let again = OgitTables::load(&ogit_checkout::root()).unwrap(); + assert!(&again == tables(), "two loads of one tree differ"); +} diff --git a/crates/ogar-action-handler/tests/ontology_cache.rs b/crates/ogar-action-handler/tests/ontology_cache.rs new file mode 100644 index 00000000..634dd3ec --- /dev/null +++ b/crates/ogar-action-handler/tests/ontology_cache.rs @@ -0,0 +1,78 @@ +//! The Ontology cache over the floating AdaWorldAPI/OGIT checkout, and how +//! its names compare with the OGIT tables'. +#![cfg(feature = "ontology-cache")] + +#[allow(dead_code)] +#[path = "../../ogar-from-schema/src/ogit_checkout.rs"] +mod ogit_checkout; + +use std::collections::BTreeSet; +use std::path::Path; + +use lance_graph_ontology::namespace::SchemaKind; +use ogar_action_handler::ogit::{OgitTables, Table, TypeId, entity_curies, load_registry}; + +/// Entities OGAR's reader returns and the cache misses, measured on +/// AdaWorldAPI/OGIT `master` at `2315167`. +const ONLY_IN_THE_TABLES: [&str; 4] = [ + "ogit.Accounting:FiscalJurisdiction", + "ogit.PTF:Dummy", + "ogit.PTF:HiroTopology", + "ogit.PTF:Test", +]; + +#[test] +fn the_cache_holds_the_hiro_classes_and_skips_the_sgo_core() { + let root = ogit_checkout::root(); + let cache = load_registry(&root).unwrap(); + for curie in [ + "ogit.Automation:ActionHandler", + "ogit.Automation:KnowledgeItem", + "ogit.Automation:MAID", + "ogit.Automation:MARSNodeTemplate", + "ogit.MARS:Machine", + ] { + let row = cache + .row_for_uri(curie) + .unwrap_or_else(|| panic!("{curie} is not in the ontology cache")); + assert_eq!(row.kind, SchemaKind::Entity, "{curie}"); + } + // lance-graph-ontology keeps `ogit.:` only. The files are in + // the checkout, so their absence is the loader's. If this fails, the cache + // now holds the SGO core: update the `ogit` module docs. + for (curie, file) in [ + ("ogit:Person", "SGO/sgo/entities/Person.ttl"), + ("ogit:dependsOn", "SGO/sgo/verbs/dependsOn.ttl"), + ("ogit:name", "SGO/sgo/attributes/name.ttl"), + ] { + assert!(root.join(file).is_file(), "{file}"); + assert!(cache.row_for_uri(curie).is_none(), "{curie}"); + } + assert!(load_registry(Path::new("/nonexistent/OGIT")).is_err()); +} + +#[test] +fn the_tables_and_the_cache_name_the_same_types_but_for_known_differences() { + let root = ogit_checkout::root(); + let cache: BTreeSet = entity_curies(&load_registry(&root).unwrap()) + .into_iter() + .collect(); + let t = OgitTables::load(&root).unwrap(); + let ours: BTreeSet = (0..t.rows(Table::Type)) + .filter_map(|i| t.type_name(TypeId(u16::try_from(i).unwrap()))) + .filter(|n| n.starts_with("ogit.")) + .map(str::to_owned) + .collect(); + // The cache also reads the files OGAR's reader rejects... + let only_cache: Vec<&String> = cache + .difference(&ours) + .filter(|c| !(c.starts_with("ogit.CustomerSupport:") || c.starts_with("ogit.Mobile:"))) + .collect(); + assert!(only_cache.is_empty(), "{only_cache:?}"); + // ...and misses a few entities OGAR's reader has. + let only_ours: Vec<&String> = ours + .difference(&cache) + .filter(|c| !ONLY_IN_THE_TABLES.contains(&c.as_str())) + .collect(); + assert!(only_ours.is_empty(), "{only_ours:?}"); +}