diff --git a/crates/od-ontology/src/alignment.rs b/crates/od-ontology/src/alignment.rs index aac7cc5..cb4c6ec 100644 --- a/crates/od-ontology/src/alignment.rs +++ b/crates/od-ontology/src/alignment.rs @@ -60,6 +60,83 @@ //! family-default-style inheritance, deferred to Phase 3); the static //! alignment data + the classifier + the lookup surface land here. +// ═══════════════════════════════════════════════════════════════════════════ +// Namespace identity — Odoo inherits from FIBO Foundations (Layer-1 declaration) +// ═══════════════════════════════════════════════════════════════════════════ + +/// The Odoo TTL namespace IRI. Every Odoo concept declared in the harvested +/// ontology lives under this base. +pub const ODOO_NAMESPACE_IRI: &str = "https://ada.world/onto/odoo#"; + +/// The Odoo OGAR ontology identity bundle. Pulled from +/// `lance_graph_ontology::hydrators::odoo` (Phase-2 repatriation). +/// Mirrors the lance-graph-ontology constants used to register the Odoo TTL +/// bundle into the `OntologyRegistry`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OntologyBundleId { + /// The bundle's graph id. `0x0002 = "Odoo"` per OGAR's app-prefix + /// allocation (`OdooPort::APP_PREFIX`). + pub graph: u32, + /// Schema version major (incremented on breaking schema changes). + pub version: u32, +} + +/// `OGIT::ODOO_V1` — the Odoo ontology identity bundle. +pub const ODOO_BUNDLE_ID: OntologyBundleId = OntologyBundleId { + graph: 0x0002, + version: 1, +}; + +/// `OGIT::FIBOFND_V1` — the FIBO Foundations bundle Odoo inherits from. +/// The cascade resolves Odoo concepts THROUGH this bundle (a Layer-1 +/// inheritance: `inherits_from: Some(OGIT::FIBOFND_V1.0)` in lance-graph's +/// hydrator). +/// +/// **This is the literal "Odoo inheriting classes" declaration** — the +/// namespace-level statement that Odoo's classes are equivalent to / subclass +/// of FIBO Foundations concepts. The per-class `owl:equivalentClass` rows in +/// [`ODOO_SEED`] are the instances of this inheritance; this constant is the +/// declaration. +pub const ODOO_INHERITS_FROM_FIBOFND_V1: OntologyBundleId = OntologyBundleId { + graph: 0x0007, // FIBO Foundations + version: 1, +}; + +/// Cascade edge-IRI whitelist for the Odoo surface — the RDF predicates the +/// symbol-table follows during transitive resolution. Pulled from +/// `lance_graph_ontology::hydrators::odoo::ODOO_EDGE_WHITELIST`. +/// +/// Per the compiler-AST frame (PR #13): these ARE the **AST-edge types** the +/// symbol-table walks. `rdfs:subClassOf` carries Odoo's facet subsumption +/// (`odoo:res.partner.Company ⊑ odoo:res.partner`); `owl:equivalentClass` +/// carries the Layer-2 alignment pivots that route into FIBO / schema.org / +/// QUDT slots (the [`ODOO_SEED`] rows). The property variants +/// (`rdfs:subPropertyOf` / `owl:equivalentProperty`) cover field-level +/// alignments (`odoo:res.partner.name owl:equivalentProperty foaf:name`, etc.). +/// +/// The first two are LOAD-BEARING — removing either breaks the symbol-table +/// (the `pivot_namespace_and_family_are_consistent` pin assumes +/// `owl:equivalentClass` resolves). +pub const ODOO_EDGE_WHITELIST: &[&str] = &[ + // Odoo facet subsumption (load-bearing — REQUIRED for the cascade) + "http://www.w3.org/2000/01/rdf-schema#subClassOf", + // Layer-2 alignment pivots into FIBO/schema/QUDT (load-bearing — REQUIRED) + "http://www.w3.org/2002/07/owl#equivalentClass", + // Field-level alignments + "http://www.w3.org/2000/01/rdf-schema#subPropertyOf", + "http://www.w3.org/2002/07/owl#equivalentProperty", +]; + +/// The shipped TTL files this crate's alignment derives from. Informational — +/// odoo-rs ships the SPO ndjson form (under `data/*.spo.ndjson`), not these +/// TTL files; the harvest pipeline that produces both runs upstream. These +/// path tails are the lance-graph-ontology runtime's source. +pub const ODOO_TTL_SOURCES: &[&str] = &[ + "data/ontologies/odoo/odoo-core.ttl", + "data/ontologies/odoo/alignment/odoo-to-fibo.ttl", + "data/ontologies/odoo/alignment/odoo-to-skr.ttl", +]; + // ═══════════════════════════════════════════════════════════════════════════ // Family bytes — restated from data/family_registry.ttl (Option B) // ═══════════════════════════════════════════════════════════════════════════ @@ -142,59 +219,147 @@ pub struct OwlPivot { /// Classify an Odoo class onto its DOLCE upper marker from structural suffix /// rules. Independent of the [`ODOO_SEED`] table so unmapped-but-recognisable /// classes (e.g. `sale.order`) still get a marker the consumer can record. +/// Accepts a bare model name (`"res.partner"`) or a prefixed IRI +/// (`"odoo:res.partner"`). +/// +/// Resolution order (first match wins): +/// +/// 1. **Endurant special-cases** — `product.template` and `account.account.template` +/// are *master data*, NOT abstract config templates (odoo's `.template` here means +/// "the master record"). Must be checked before the `.template` Abstract rule. +/// 2. **Perdurant** — transactional events / processes: `*.move.line`, `*.move`, +/// `*.payment`, `*.order.line`, `*.order`, `*.message`, `*.activity`, +/// `*.attendance`, `*.transition`, `*.event`, `*.log`, `*.history`, +/// `*.transaction`, `*.picking`, `*.scrap`, `bank.statement`, `stock.move`. +/// 3. **Abstract** — rules / classifications / templates: `*.tax`, +/// `*.fiscal.position`, `*.reconcile.model`, `*.payment.term`, +/// `*.pricelist.item`, `*.pricelist`, `*.template`, `*.config`, `*.policy`, +/// `*.rule`, `*.formula`. +/// 4. **Quality** — dimensions / classifications / rates: `uom.*`, `*.tag`, +/// `*.type`, `*.group`, `*.category`. +/// 5. **Endurant** — persistent master-data fallback: `res.*`, `*.account`, +/// `product.*`. +/// 6. **Unknown** — no rule matched (deliberately NOT a default-to-Endurant +/// rule, contra `lance_graph_ontology::hydrators::dolce_odoo::classify_odoo` +/// which defaults to Endurant; see § "Two-classifier disagreement" below). /// -/// - **Perdurant** — transactional events / processes: `*.move`, -/// `*.move.line`, `*.payment`, `*.order`, `*.order.line`, bank statements, -/// stock moves, pickings. -/// - **Abstract** — rules / classifications / models: `*.tax`, -/// `*.fiscal.position`, `*.reconcile.model`, `*.payment.term`, -/// `*.pricelist`, `*.pricelist.item` (pricing rules, not persistent goods). -/// - **Quality** — dimensions of comparison: `uom.*`. -/// - **Endurant** — persistent master-data: `res.*`, `*.account`, `product.*` -/// (the Abstract rules above match FIRST, so `product.pricelist` is -/// correctly Abstract rather than swept into Endurant by `product.*`). -/// - **Unknown** — no rule matched. +/// # Classifier provenance — two lance-graph classifiers merged here /// -/// # Classifier completeness (finding from cross-validation) +/// lance-graph hosts **two** disagreeing DOLCE classifiers for Odoo: /// -/// The original `lance_graph_callcenter::odoo_alignment::dolce_odoo` lacked -/// the `pricelist` exception. The seed table declared `product.pricelist` as -/// `Abstract` (correctly: it's a pricing rule, not a persistent product), but -/// the classifier returned `Endurant` because `product.*` matched first. -/// Surfaced by the cross-validation test `every_seed_rows_dolce_matches_its -/// _classifier_assignment` (Phase-2 alignment-pin, this crate). +/// - `lance_graph_callcenter::odoo_alignment::dolce_odoo` (pulled in PR #14) +/// — fewer suffix rules, returns `Unknown` for unmatched. +/// - `lance_graph_ontology::hydrators::dolce_odoo::classify_odoo` (pulled here) +/// — richer suffix lists (3-way split into Perdurant / Quality / Abstract), +/// defaults to `Endurant` for unmatched. +/// +/// This implementation merges the **richer** suffix lists from the hydrator +/// while keeping the alignment-side `Unknown` default and the alignment seed's +/// disambiguation of `.tax` and the `.template` special-cases. See § +/// "Two-classifier disagreement" for the explicit reconciliation table. +/// +/// # Two-classifier disagreement (findings, deliberate reconciliations) +/// +/// | Case | callcenter (PR #14) | ontology hydrator | This crate (reconciled) | Why | +/// |---|---|---|---|---| +/// | `.tax` | Abstract | Quality | **Abstract** | A tax IS a rule, not just a rate. Matches the [`ODOO_SEED`] (no row, but consistent with `payment.term` / `fiscal.position`). Phase-3 OGAR-side may revisit. | +/// | `.tag` / `.type` / `.group` / `.category` | unmatched → Endurant via fallback | Quality | **Quality** | Classifications. The hydrator is correct: a category IS a way to classify, a Quality dimension. | +/// | `.template` | unmatched → Endurant via product.* | Abstract (with `product.template` special-case) | **Abstract** with `product.template` AND `account.account.template` special-cases | Templates ARE configurations (Abstract) — except where Odoo uses "template" for master records (the two SKR-aligned exceptions). | +/// | `hr.*` | Unknown | Endurant by default | **Unknown** | Deliberate: the seam table disagrees with the seed (`hr.job` / `hr.contract` are Abstract per the seed) and the hydrator (Endurant by default). Keeping Unknown surfaces the gap rather than picking one. | +/// | Default | Unknown | Endurant | **Unknown** | Unknown signals "no rule matched" — the consumer can choose to default. Hiding this behind a fallback obscures the gap surface. | +/// +/// The reconciliations are CONJECTURE-grade — Phase 3's universal Class-side +/// push to OGAR is where the canonical resolution lands. #[must_use] #[allow(clippy::case_sensitive_file_extension_comparisons)] // Odoo class-name suffixes are not file extensions pub fn dolce_odoo(class: &str) -> DolceMarker { - if class.ends_with(".move") - || class.ends_with(".move.line") - || class.ends_with(".payment") - || class.ends_with(".order") - || class.ends_with(".order.line") - || class.ends_with("bank.statement") - || class.ends_with(".picking") - || class == "stock.move" + let model = strip_odoo_prefix(class); + + // (1) Endurant special-cases — Odoo uses `.template` here for the master + // record, NOT an abstract config template. Must check BEFORE the + // `.template` Abstract rule below. + if model == "product.template" || model == "account.account.template" { + return DolceMarker::Endurant; + } + + // (2) Perdurant — transactional events / processes. + if model.ends_with(".move.line") + || model.ends_with(".move") + || model.ends_with(".payment") + || model.ends_with(".order.line") + || model.ends_with(".order") + || model.ends_with(".message") + || model.ends_with(".activity") + || model.ends_with(".attendance") + || model.ends_with(".transition") + || model.ends_with(".event") + || model.ends_with(".log") + || model.ends_with(".history") + || model.ends_with(".transaction") + || model.ends_with(".picking") + || model.ends_with(".scrap") + || model.ends_with("bank.statement") + || model == "stock.move" { return DolceMarker::Perdurant; } - if class.ends_with(".tax") - || class.ends_with("fiscal.position") - || class.ends_with("reconcile.model") - || class.ends_with("payment.term") - || class.ends_with(".pricelist") - || class.ends_with(".pricelist.item") + + // (3) Abstract — rules / policies / templates / classifications. + // NOTE: `.settings` is the real catch for `*.config.settings` models + // (the canonical Odoo settings shape, e.g. `sale.config.settings`). + // The hydrator-side `.config` rule had a misleading comment claiming + // to match `*.config.settings` but did not — `res.config.settings` + // ends with `.settings`, not `.config`. Both are listed here so a + // class ending in either lands as Abstract. + if model.ends_with(".tax") + || model.ends_with("fiscal.position") + || model.ends_with("reconcile.model") + || model.ends_with("payment.term") + || model.ends_with(".pricelist.item") + || model.ends_with(".pricelist") + || model.ends_with(".template") + || model.ends_with(".settings") + || model.ends_with(".config") + || model.ends_with(".policy") + || model.ends_with(".rule") + || model.ends_with(".formula") { return DolceMarker::Abstract; } - if class.starts_with("uom.") { + + // (4) Quality — dimensions / classifications / rates. + // NOTE: `.groups` (plural) is the real Odoo class shape — the + // canonical `res.groups`. The hydrator-side `.group` rule had a + // misleading comment claiming to match `res.groups` but did not. + // Both singular and plural are listed. + if model.starts_with("uom.") + || model.ends_with(".tag") + || model.ends_with(".type") + || model.ends_with(".groups") + || model.ends_with(".group") + || model.ends_with(".category") + { return DolceMarker::Quality; } - if class.starts_with("res.") || class.ends_with(".account") || class.starts_with("product.") { + + // (5) Endurant — persistent master-data fallback. + if model.starts_with("res.") || model.ends_with(".account") || model.starts_with("product.") { return DolceMarker::Endurant; } + DolceMarker::Unknown } +/// Strip a leading `odoo:` namespace prefix or the full odoo namespace IRI, +/// returning the bare Odoo model name. Idempotent — bare names pass through. +#[must_use] +pub fn strip_odoo_prefix(iri: &str) -> &str { + if let Some(rest) = iri.strip_prefix("https://ada.world/onto/odoo#") { + return rest; + } + iri.strip_prefix("odoo:").unwrap_or(iri) +} + // ═══════════════════════════════════════════════════════════════════════════ // ODOO_SEED — the muscle memory (15 declared alignment rows) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/crates/od-ontology/src/lib.rs b/crates/od-ontology/src/lib.rs index 2469a5d..252c804 100644 --- a/crates/od-ontology/src/lib.rs +++ b/crates/od-ontology/src/lib.rs @@ -77,9 +77,11 @@ pub use ogar_actions::{corpus_action_rows, corpus_to_actions}; pub use ogar_vocab::class_ids; pub use alignment::{ - dolce_odoo, resolve_odoo, DolceMarker, OdooFamily, OdooSeedRow, OwlPivot, FAMILY_BILLING_CORE, - FAMILY_HR_FOUNDATION, FAMILY_PRODUCT_CATALOG, FAMILY_SMB_ACCOUNTING, - FAMILY_SMB_FOUNDRY_CUSTOMER, FAMILY_SMB_FOUNDRY_INVOICE, ODOO_SEED, + dolce_odoo, resolve_odoo, strip_odoo_prefix, DolceMarker, OdooFamily, OdooSeedRow, + OntologyBundleId, OwlPivot, FAMILY_BILLING_CORE, FAMILY_HR_FOUNDATION, FAMILY_PRODUCT_CATALOG, + FAMILY_SMB_ACCOUNTING, FAMILY_SMB_FOUNDRY_CUSTOMER, FAMILY_SMB_FOUNDRY_INVOICE, ODOO_BUNDLE_ID, + ODOO_EDGE_WHITELIST, ODOO_INHERITS_FROM_FIBOFND_V1, ODOO_NAMESPACE_IRI, ODOO_SEED, + ODOO_TTL_SOURCES, }; pub use emit::corpus_to_schema; pub use inheritance::InheritanceMap; diff --git a/crates/od-ontology/tests/alignment_pin.rs b/crates/od-ontology/tests/alignment_pin.rs index 609749c..647cdb0 100644 --- a/crates/od-ontology/tests/alignment_pin.rs +++ b/crates/od-ontology/tests/alignment_pin.rs @@ -15,9 +15,10 @@ //! 5. The lookup surface — exact match + `product.*` prefix fallback. use od_ontology::{ - dolce_odoo, parse_ndjson, resolve_odoo, DolceMarker, FAMILY_BILLING_CORE, FAMILY_HR_FOUNDATION, - FAMILY_PRODUCT_CATALOG, FAMILY_SMB_ACCOUNTING, FAMILY_SMB_FOUNDRY_CUSTOMER, - FAMILY_SMB_FOUNDRY_INVOICE, ODOO_SEED, + dolce_odoo, parse_ndjson, resolve_odoo, strip_odoo_prefix, DolceMarker, FAMILY_BILLING_CORE, + FAMILY_HR_FOUNDATION, FAMILY_PRODUCT_CATALOG, FAMILY_SMB_ACCOUNTING, + FAMILY_SMB_FOUNDRY_CUSTOMER, FAMILY_SMB_FOUNDRY_INVOICE, ODOO_BUNDLE_ID, ODOO_EDGE_WHITELIST, + ODOO_INHERITS_FROM_FIBOFND_V1, ODOO_NAMESPACE_IRI, ODOO_SEED, ODOO_TTL_SOURCES, }; const SLICE_1: &str = include_str!("../../../data/account_move.spo.ndjson"); @@ -251,10 +252,172 @@ fn resolve_odoo_exact_match_returns_seed_row() { #[test] fn resolve_odoo_unseen_product_subtype_inherits_generic_slot() { - let p = resolve_odoo("product.category").expect("product.* prefix fallback"); + // product.attribute hits the product.* prefix fallback. Classifier returns + // Endurant (default product.* rule after no Perdurant/Abstract/Quality + // match), so the pivot's DOLCE matches the master-record interpretation. + let p = resolve_odoo("product.attribute").expect("product.* prefix fallback"); assert_eq!(p.pivot_uri, "schema:Product"); assert_eq!(p.family, FAMILY_BILLING_CORE); assert_eq!(p.slot, 1); // product.template's slot + assert_eq!(p.dolce, DolceMarker::Endurant); +} + +#[test] +fn resolve_odoo_product_category_inherits_pivot_with_quality_dolce() { + // The richer classifier (PR #15) classifies `*.category` as Quality. + // `product.category` therefore resolves via `product.*` prefix fallback + // for the pivot+family+slot, but its DOLCE is Quality not Endurant — + // the marker correctly reflects that category is a classification, not + // a persistent product. (Phase-3 OGAR-side will decide whether the + // pivot itself should differ for Quality classes.) + let p = resolve_odoo("product.category").expect("product.* prefix fallback"); + assert_eq!(p.pivot_uri, "schema:Product"); // inherited via product.template + assert_eq!(p.family, FAMILY_BILLING_CORE); + assert_eq!(p.dolce, DolceMarker::Quality); +} + +// ── §7: richer dolce_odoo classifier (PR #15 — pulled from hydrators) ────── + +#[test] +fn dolce_classifier_handles_prefixed_iris() { + // Bare model + odoo: prefix + full namespace IRI all resolve to the same + // category. Pulled from `lance_graph_ontology::hydrators::dolce_odoo`. + assert_eq!(dolce_odoo("res.partner"), DolceMarker::Endurant); + assert_eq!(dolce_odoo("odoo:res.partner"), DolceMarker::Endurant); + assert_eq!( + dolce_odoo("https://ada.world/onto/odoo#res.partner"), + DolceMarker::Endurant + ); +} + +#[test] +fn strip_odoo_prefix_handles_all_three_forms() { + assert_eq!(strip_odoo_prefix("res.partner"), "res.partner"); + assert_eq!(strip_odoo_prefix("odoo:res.partner"), "res.partner"); + assert_eq!( + strip_odoo_prefix("https://ada.world/onto/odoo#account.move"), + "account.move" + ); +} + +#[test] +fn dolce_classifier_new_perdurant_suffix_rules() { + // Pulled from `hydrators::dolce_odoo::PERDURANT_SUFFIXES`. + assert_eq!(dolce_odoo("mail.message"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("mail.activity"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("hr.attendance"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("calendar.event"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("audit.log"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("payment.transaction"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("stock.scrap"), DolceMarker::Perdurant); +} + +#[test] +fn dolce_classifier_new_quality_suffix_rules() { + // Pulled from `hydrators::dolce_odoo::QUALITY_SUFFIXES` (minus `.tax`, + // which we keep as Abstract — see § "Two-classifier disagreement" in + // `alignment.rs`). + assert_eq!(dolce_odoo("crm.tag"), DolceMarker::Quality); + assert_eq!(dolce_odoo("account.account.type"), DolceMarker::Quality); + assert_eq!(dolce_odoo("res.groups"), DolceMarker::Quality); + assert_eq!(dolce_odoo("product.category"), DolceMarker::Quality); +} + +#[test] +fn dolce_classifier_new_abstract_suffix_rules() { + // Pulled from `hydrators::dolce_odoo::ABSTRACT_SUFFIXES`. + assert_eq!(dolce_odoo("mail.template"), DolceMarker::Abstract); + assert_eq!(dolce_odoo("res.config.settings"), DolceMarker::Abstract); + // .policy / .rule / .formula are pulled but not test-exercised by any + // shipped model name in the slice corpora; the suffix rule is present so + // any future Odoo class ending in `.policy` lands correctly. +} + +#[test] +fn dolce_classifier_keeps_endurant_special_cases() { + // `product.template` and `account.account.template` are master records, + // NOT abstract config templates. Must NOT be swept into Abstract by + // the `.template` suffix rule. + assert_eq!(dolce_odoo("product.template"), DolceMarker::Endurant); + assert_eq!( + dolce_odoo("account.account.template"), + DolceMarker::Endurant + ); +} + +#[test] +fn dolce_classifier_documented_disagreement_tax_stays_abstract() { + // Disagreement with `lance_graph_ontology::hydrators::dolce_odoo` (which + // classifies `.tax` as Quality). This crate keeps `.tax` → Abstract + // (matches `lance_graph_callcenter::odoo_alignment` PR #14 pull). + // Phase-3 OGAR-side will canonically resolve. + assert_eq!(dolce_odoo("account.tax"), DolceMarker::Abstract); +} + +// ── §8: namespace identity + cascade edge whitelist ──────────────────────── + +#[test] +fn namespace_iri_matches_lance_graph_hydrator() { + assert_eq!(ODOO_NAMESPACE_IRI, "https://ada.world/onto/odoo#"); +} + +#[test] +fn odoo_bundle_inherits_from_fibo_foundations() { + // The literal "Odoo inheriting classes" namespace declaration: Odoo + // (bundle 0x0002) inherits from FIBO Foundations (bundle 0x0007). The + // per-class equivalentClass rows in ODOO_SEED are the instances of this + // inheritance; these constants are the declaration. + assert_eq!(ODOO_BUNDLE_ID.graph, 0x0002); + assert_eq!(ODOO_BUNDLE_ID.version, 1); + assert_eq!(ODOO_INHERITS_FROM_FIBOFND_V1.graph, 0x0007); + assert_eq!(ODOO_INHERITS_FROM_FIBOFND_V1.version, 1); +} + +#[test] +fn cascade_edge_whitelist_has_the_two_load_bearing_iris() { + // Per the compiler-AST frame: these are the AST-edge types the symbol + // table follows. `rdfs:subClassOf` (facet subsumption) and + // `owl:equivalentClass` (Layer-2 alignment pivots into FIBO/schema/QUDT) + // are LOAD-BEARING — removing either breaks the cascade. + assert!( + ODOO_EDGE_WHITELIST.contains(&"http://www.w3.org/2000/01/rdf-schema#subClassOf"), + "rdfs:subClassOf is load-bearing" + ); + assert!( + ODOO_EDGE_WHITELIST.contains(&"http://www.w3.org/2002/07/owl#equivalentClass"), + "owl:equivalentClass is load-bearing" + ); +} + +#[test] +fn cascade_edge_whitelist_covers_property_alignments_too() { + // Field-level alignments (e.g. `odoo:res.partner.name owl:equivalentProperty + // foaf:name`) require the property variants of the two load-bearing edges. + assert!( + ODOO_EDGE_WHITELIST.contains(&"http://www.w3.org/2000/01/rdf-schema#subPropertyOf"), + "rdfs:subPropertyOf carries field-level subsumption" + ); + assert!( + ODOO_EDGE_WHITELIST.contains(&"http://www.w3.org/2002/07/owl#equivalentProperty"), + "owl:equivalentProperty carries field-level alignments" + ); +} + +#[test] +fn ttl_sources_list_the_three_documented_files() { + // Mirrors `lance_graph_ontology::hydrators::odoo::ODOO_CORE_RELATIVE_PATH` + // + `ODOO_ALIGNMENT_RELATIVE_PATHS`. odoo-rs ships the SPO ndjson form, + // not these TTL files — they're documented here so the consumer knows + // what the harvest pipeline reads upstream. + assert!(ODOO_TTL_SOURCES.contains(&"data/ontologies/odoo/odoo-core.ttl")); + assert!( + ODOO_TTL_SOURCES.contains(&"data/ontologies/odoo/alignment/odoo-to-fibo.ttl"), + "odoo-to-fibo TTL is the Layer-2 alignment overlay" + ); + assert!( + ODOO_TTL_SOURCES.contains(&"data/ontologies/odoo/alignment/odoo-to-skr.ttl"), + "odoo-to-skr TTL carries SKR03/04 chart-of-accounts alignment" + ); } #[test]