diff --git a/crates/od-ontology/src/alignment.rs b/crates/od-ontology/src/alignment.rs new file mode 100644 index 0000000..aac7cc5 --- /dev/null +++ b/crates/od-ontology/src/alignment.rs @@ -0,0 +1,398 @@ +//! OWL pivot alignment — the **symbol table** of the Odoo language frontend. +//! +//! # The compiler-AST role +//! +//! Per `triple.rs` § "The compiler frame": lance-graph on OGAR is acting like +//! a compiler. This module IS the **symbol table / type-resolver** for the +//! Odoo language frontend — Odoo class name → OGAR `(family, slot)` identity +//! + `owl:equivalentClass` pivot URI (`fibo:LegalEntity`, `fibo:Transaction`, +//! `schema:Product`, `qudt:Unit`, …) + DOLCE upper marker. +//! +//! ```text +//! odoo class ──owl:equivalentClass──► OWL pivot URI ──► OGIT family + slot +//! resolve_odoo() (fibo / schema / (Option B: inherit +//! qudt / vcard / existing family; +//! org) never mint new) +//! ``` +//! +//! # Empowerment shapes (universal, eventual OGAR push) +//! +//! Three universal patterns this module deposits — Phase 3 of the +//! `specs/REPATRIATION-FRAME.md` will push them to OGAR as universal +//! `Class` capabilities so other source languages (Rails, Elixir, Django, …) +//! reuse them: +//! +//! - **[`OwlPivot`]** — the resolved landing point shape `(pivot_uri, family, +//! slot, dolce)`. Any source-language→OGAR alignment table has rows of this +//! shape; the names are universal. +//! - **[`dolce_odoo`]** — suffix-pattern DOLCE classifier. The *mechanism* is +//! universal (suffix-match → upper-category marker); the *patterns* are +//! Odoo-specific muscle memory. The mechanism splits cleanly: a parameterised +//! classifier with the patterns supplied per-language. +//! - **[`resolve_odoo`]** — symbol-table resolution with prefix fallback. Same +//! shape for every per-language symbol table. +//! +//! # Muscle memory (Odoo specifics, stays here) +//! +//! - The 15 [`ODOO_SEED`] rows — `res.partner → fibo:LegalEntity`, +//! `account.move → fibo:Transaction`, … These are declarations of Odoo's +//! convention for which FIBO / schema.org / QUDT concept each ORM model +//! instantiates. Pure muscle memory; the per-row knowledge IS Odoo. +//! - The DOLCE suffix patterns — `.move` ⇒ Perdurant, `res.` ⇒ Endurant, +//! `.tax` ⇒ Abstract, `uom.` ⇒ Quality. Odoo naming conventions. +//! - The 6 family-byte constants — `BillingCore = 0x61`, `SMBAccounting = +//! 0x62`, etc. These restate the bytes assigned in lance-graph's +//! `data/family_registry.ttl`; the assignments are stable and authoritative. +//! +//! # Identity (in OGAR codebook — `OdooPort`) +//! +//! The fact that "Odoo's `res.partner` is a customer" lives canonically in +//! OGAR's `OdooPort::class_id("res.partner") → CUSTOMER` (PR #94/#95). This +//! module's seed table carries the *complementary* axis — which FIBO/schema +//! pivot + DOLCE marker each Odoo class lands on — and the +//! [`tests/alignment_pin.rs`](`super`) tests cross-validate the two. +//! +//! # Phase-2 pull provenance +//! +//! Pulled from `lance_graph_callcenter::odoo_alignment` per +//! `specs/REPATRIATION-FRAME.md`. The lance-graph file additionally wires +//! into `OgitFamilyTable` (runtime hydration) and `StyleCluster` (the +//! family-default-style inheritance, deferred to Phase 3); the static +//! alignment data + the classifier + the lookup surface land here. + +// ═══════════════════════════════════════════════════════════════════════════ +// Family bytes — restated from data/family_registry.ttl (Option B) +// ═══════════════════════════════════════════════════════════════════════════ + +/// A foundry family byte — the basin an OGIT identity lives in. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct OdooFamily(pub u8); + +impl OdooFamily { + /// The raw byte. + #[must_use] + pub const fn raw(self) -> u8 { + self.0 + } +} + +/// `ogit:BillingCore` — billable items / billing surface. familyId 97 (`0x61`). +pub const FAMILY_BILLING_CORE: OdooFamily = OdooFamily(0x61); +/// `ogit:SMBAccounting` — double-entry substrate (accounts, posting lines). +/// familyId 98 (`0x62`). +pub const FAMILY_SMB_ACCOUNTING: OdooFamily = OdooFamily(0x62); +/// `ogit:ProductCatalog` — product catalogue + pricelist + `UoM`. familyId 100 +/// (`0x64`). NOTE: the lance-graph family registry assigns `0x64`, NOT the +/// `0x63` (=99 `ogit:MRORepair`) named in earlier proposals. +pub const FAMILY_PRODUCT_CATALOG: OdooFamily = OdooFamily(0x64); +/// `ogit:SmbFoundryCustomer` — partner / legal-entity master data. familyId +/// 128 (`0x80`). +pub const FAMILY_SMB_FOUNDRY_CUSTOMER: OdooFamily = OdooFamily(0x80); +/// `ogit:SmbFoundryInvoice` — invoice / transaction document. familyId 129 +/// (`0x81`). +pub const FAMILY_SMB_FOUNDRY_INVOICE: OdooFamily = OdooFamily(0x81); +/// `ogit:HRFoundation` — employee / org / job / base-contract. familyId 144 +/// (`0x90`). Base HR data only; payroll engine is Odoo Enterprise (absent). +pub const FAMILY_HR_FOUNDATION: OdooFamily = OdooFamily(0x90); + +// ═══════════════════════════════════════════════════════════════════════════ +// DolceMarker — DOLCE upper-category marker +// ═══════════════════════════════════════════════════════════════════════════ + +/// DOLCE upper-ontology marker — the topmost category an Odoo class lands on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DolceMarker { + /// Persistent objects — `res.partner`, `account.account`, `product.template`. + Endurant, + /// Transactional events / processes — `account.move`, `sale.order`, `stock.move`. + Perdurant, + /// Dimensions of comparison — `uom.uom`, `uom.category`. + Quality, + /// Rules, classifications, models — `account.tax`, `account.fiscal.position`. + Abstract, + /// No suffix rule matched. + Unknown, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// OwlPivot — the empowerment shape (universal across source languages) +// ═══════════════════════════════════════════════════════════════════════════ + +/// The resolved `owl:equivalentClass` landing for an Odoo class: the pivot +/// URI, the inherited foundry family + slot, and the DOLCE marker. This +/// **shape** is universal — any per-language alignment table has rows of this +/// shape. The contents are per-language. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OwlPivot { + /// `owl:equivalentClass` target, e.g. `"fibo:LegalEntity"`, + /// `"fibo:Transaction"`, `"schema:Product"`. + pub pivot_uri: &'static str, + /// Inherited foundry basin. + pub family: OdooFamily, + /// Inherited within-family slot. + pub slot: u16, + /// DOLCE upper marker (Endurant / Perdurant / Quality / Abstract). + pub dolce: DolceMarker, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// dolce_odoo — DOLCE marker from Odoo class suffix rules (muscle memory) +// ═══════════════════════════════════════════════════════════════════════════ + +/// 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. +/// +/// - **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 completeness (finding from cross-validation) +/// +/// 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). +#[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" + { + 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") + { + return DolceMarker::Abstract; + } + if class.starts_with("uom.") { + return DolceMarker::Quality; + } + if class.starts_with("res.") || class.ends_with(".account") || class.starts_with("product.") { + return DolceMarker::Endurant; + } + DolceMarker::Unknown +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ODOO_SEED — the muscle memory (15 declared alignment rows) +// ═══════════════════════════════════════════════════════════════════════════ + +/// One static alignment row — Odoo class → OWL pivot + inherited +/// (family, slot) + DOLCE + canonical label + provenance. +#[derive(Clone, Copy, Debug)] +pub struct OdooSeedRow { + /// Odoo model name (the resolvable key). + pub odoo_class: &'static str, + /// `owl:equivalentClass` pivot URI. + pub pivot_uri: &'static str, + /// Inherited family byte. + pub family: OdooFamily, + /// Inherited slot within the family. + pub slot: u16, + /// DOLCE upper marker. + pub dolce: DolceMarker, + /// Canonical OGIT label this slot carries inside the foundry family table. + pub label_uri: &'static str, + /// `dcterms:source` lineage stamped into the FamilyEntry on hydration. + pub provenance: &'static str, +} + +/// The declared alignment rows — fixed table; linear scan is effectively O(1). +/// +/// Each row asserts: "Odoo's `` IS-A ``, and therefore +/// inherits OGIT family `` slot ``." This is the muscle memory +/// Odoo deposits — the BillingCore / SMBAccounting / SmbFoundryCustomer / +/// SmbFoundryInvoice / ProductCatalog / HRFoundation basins are *not* invented; +/// they are existing foundry families the per-row OWL pivot routes into. +pub static ODOO_SEED: &[OdooSeedRow] = &[ + OdooSeedRow { + odoo_class: "res.partner", + pivot_uri: "fibo:LegalEntity", + family: FAMILY_SMB_FOUNDRY_CUSTOMER, + slot: 1, + dolce: DolceMarker::Endurant, + label_uri: "ogit.SMB:Customer", + provenance: "odoo res.partner (company facet) =owl:equivalentClass=> fibo:LegalEntity", + }, + OdooSeedRow { + odoo_class: "account.move", + pivot_uri: "fibo:Transaction", + family: FAMILY_SMB_FOUNDRY_INVOICE, + slot: 1, + dolce: DolceMarker::Perdurant, + label_uri: "ogit.SMB:Invoice", + provenance: "odoo account.move =owl:equivalentClass=> fibo:Transaction", + }, + OdooSeedRow { + odoo_class: "account.move.line", + pivot_uri: "fibo:JournalEntryLine", + family: FAMILY_SMB_ACCOUNTING, + slot: 1, + dolce: DolceMarker::Perdurant, + label_uri: "ogit.SMBAccounting:JournalEntryLine", + provenance: "odoo account.move.line =owl:equivalentClass=> fibo:JournalEntryLine", + }, + OdooSeedRow { + odoo_class: "account.account", + pivot_uri: "fibo:Account", + family: FAMILY_SMB_ACCOUNTING, + slot: 2, + dolce: DolceMarker::Endurant, + label_uri: "ogit.SMBAccounting:Account", + provenance: "odoo account.account =owl:equivalentClass=> fibo:Account", + }, + OdooSeedRow { + odoo_class: "account.account.template", + pivot_uri: "fibo:Account", + family: FAMILY_SMB_ACCOUNTING, + slot: 3, + dolce: DolceMarker::Endurant, + label_uri: "ogit.SMBAccounting:SkrAccount", + provenance: "SKR03/04 chart concept (odoo account.account.template) => fibo:Account", + }, + OdooSeedRow { + odoo_class: "product.template", + pivot_uri: "schema:Product", + family: FAMILY_BILLING_CORE, + slot: 1, + dolce: DolceMarker::Endurant, + label_uri: "ogit.Billing:Product", + provenance: "odoo product.template =owl:equivalentClass=> schema:Product", + }, + OdooSeedRow { + odoo_class: "product.product", + pivot_uri: "schema:Product", + family: FAMILY_BILLING_CORE, + slot: 2, + dolce: DolceMarker::Endurant, + label_uri: "ogit.Billing:ProductVariant", + provenance: "odoo product.product (variant) =owl:equivalentClass=> schema:Product", + }, + // ── ProductCatalog 0x64 — catalogue STRUCTURE (pricing + measurement) ── + // product.template / product.product stay on BillingCore (0x61): they are + // billable ITEMS. The pricelist / UoM concepts are catalogue STRUCTURE. + OdooSeedRow { + odoo_class: "product.pricelist", + pivot_uri: "schema:PriceSpecification", + family: FAMILY_PRODUCT_CATALOG, + slot: 1, + dolce: DolceMarker::Abstract, + label_uri: "ogit.ProductCatalog:Pricelist", + provenance: "odoo product.pricelist =owl:equivalentClass=> schema:PriceSpecification", + }, + OdooSeedRow { + odoo_class: "product.pricelist.item", + pivot_uri: "schema:UnitPriceSpecification", + family: FAMILY_PRODUCT_CATALOG, + slot: 2, + dolce: DolceMarker::Abstract, + label_uri: "ogit.ProductCatalog:PricelistRule", + provenance: "odoo product.pricelist.item =owl:equivalentClass=> schema:UnitPriceSpecification", + }, + OdooSeedRow { + // uom.uom ties into the QUDT Foundation namespace (qudt:Unit) — the + // measurement spine. + odoo_class: "uom.uom", + pivot_uri: "qudt:Unit", + family: FAMILY_PRODUCT_CATALOG, + slot: 3, + dolce: DolceMarker::Quality, + label_uri: "ogit.ProductCatalog:UnitOfMeasure", + provenance: "odoo uom.uom =owl:equivalentClass=> qudt:Unit", + }, + // ── HRFoundation 0x90 — employee / org / job / base-contract ── + // Payroll ENGINE is Odoo Enterprise (absent): only base HR data aligns here. + OdooSeedRow { + odoo_class: "hr.employee", + pivot_uri: "vcard:Individual", + family: FAMILY_HR_FOUNDATION, + slot: 1, + dolce: DolceMarker::Endurant, + label_uri: "ogit.HR:Employee", + provenance: "odoo hr.employee =owl:equivalentClass=> vcard:Individual", + }, + OdooSeedRow { + odoo_class: "hr.department", + pivot_uri: "org:OrganizationalUnit", + family: FAMILY_HR_FOUNDATION, + slot: 2, + dolce: DolceMarker::Endurant, + label_uri: "ogit.HR:Department", + provenance: "odoo hr.department =owl:equivalentClass=> org:OrganizationalUnit", + }, + OdooSeedRow { + odoo_class: "hr.job", + pivot_uri: "org:Role", + family: FAMILY_HR_FOUNDATION, + slot: 3, + dolce: DolceMarker::Abstract, + label_uri: "ogit.HR:Job", + provenance: "odoo hr.job =owl:equivalentClass=> org:Role", + }, + OdooSeedRow { + // Base employment contract only — payroll computation is Enterprise. + odoo_class: "hr.contract", + pivot_uri: "fibo:Contract", + family: FAMILY_HR_FOUNDATION, + slot: 4, + dolce: DolceMarker::Abstract, + label_uri: "ogit.HR:EmploymentContract", + provenance: "odoo hr.contract (base, payroll is Enterprise/absent) =owl:equivalentClass=> fibo:Contract", + }, +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// Resolution surface — the symbol-table lookup +// ═══════════════════════════════════════════════════════════════════════════ + +/// Resolve an Odoo class to its OWL pivot. Exact seed match first; then a +/// `product.*` prefix fallback onto the generic `product.template` row +/// (schema:Product / BillingCore). Returns `None` for any class with no +/// existing family — that is the signal to author a Layer-2 alignment axiom, +/// not to invent a family. +#[must_use] +pub fn resolve_odoo(class: &str) -> Option { + if let Some(row) = ODOO_SEED.iter().find(|r| r.odoo_class == class) { + return Some(OwlPivot { + pivot_uri: row.pivot_uri, + family: row.family, + slot: row.slot, + dolce: row.dolce, + }); + } + // Unseen product subtype → inherit the generic product slot. + if class.starts_with("product.") { + let row = ODOO_SEED + .iter() + .find(|r| r.odoo_class == "product.template")?; + return Some(OwlPivot { + pivot_uri: row.pivot_uri, + family: row.family, + slot: row.slot, + dolce: dolce_odoo(class), + }); + } + None +} diff --git a/crates/od-ontology/src/lib.rs b/crates/od-ontology/src/lib.rs index d72948d..2469a5d 100644 --- a/crates/od-ontology/src/lib.rs +++ b/crates/od-ontology/src/lib.rs @@ -47,6 +47,7 @@ //! println!("{}", schema.to_sql()); //! ``` +mod alignment; mod emit; mod inheritance; #[cfg(feature = "ogar-emit")] @@ -75,6 +76,11 @@ pub use ogar_actions::{corpus_action_rows, corpus_to_actions}; #[cfg(feature = "ogar-emit")] 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, +}; pub use emit::corpus_to_schema; pub use inheritance::InheritanceMap; pub use recompute_dag::{MethodId, MethodKind, RecomputeDag}; diff --git a/crates/od-ontology/tests/alignment_pin.rs b/crates/od-ontology/tests/alignment_pin.rs new file mode 100644 index 0000000..609749c --- /dev/null +++ b/crates/od-ontology/tests/alignment_pin.rs @@ -0,0 +1,318 @@ +//! Invariant pins for the Odoo OWL pivot alignment. +//! +//! Cross-validates the static [`ODOO_SEED`] (muscle memory pulled from +//! `lance_graph_callcenter::odoo_alignment` per `specs/REPATRIATION-FRAME.md` +//! Phase 2) against: +//! +//! 1. The SPO corpus — every seeded Odoo class must appear as a Subject +//! (`rdf:type ogit:ObjectType`) in at least one shipped slice. +//! 2. The documented family-byte registry (`0x61` BillingCore, `0x62` +//! SMBAccounting, `0x64` ProductCatalog, `0x80` SmbFoundryCustomer, +//! `0x81` SmbFoundryInvoice, `0x90` HRFoundation). +//! 3. The pivot-URI namespace conventions (`fibo:` / `schema:` / `qudt:` / +//! `vcard:` / `org:`). +//! 4. The DOLCE classifier behavior pinned to its documented suffix rules. +//! 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, +}; + +const SLICE_1: &str = include_str!("../../../data/account_move.spo.ndjson"); +const SLICE_2: &str = include_str!("../../../data/slice_2.spo.ndjson"); + +// ── §1: every seeded class is a real Odoo class in at least one slice ────── + +/// Every `ODOO_SEED.odoo_class` must appear as a Subject of `rdf:type +/// ogit:ObjectType` in at least one shipped slice — or, for `account.*`- +/// flavoured classes that the corpus emits under the underscore form +/// (`account_move`), via the canonical `.` shape's model prefix. +/// The seed is only useful if its keys refer to real Odoo classes; this catches +/// the case where a seed row goes stale because Odoo renamed the model. +#[test] +fn every_seeded_class_appears_in_a_corpus_slice() { + let s1 = parse_ndjson(SLICE_1).expect("slice-1 parses"); + let s2 = parse_ndjson(SLICE_2).expect("slice-2 parses"); + let in_any_slice = |odoo_class: &str| -> bool { + let dotted = odoo_class; // raw form used in the corpus values (target / inverse_name) + let underscored = odoo_class.replace('.', "_"); // form used in `odoo:` IRIs + let needles_iri = format!("odoo:{}", underscored); + let appears = |triples: &[od_ontology::Triple]| -> bool { + triples.iter().any(|t| { + // model prefix match in IRI subjects (account_move family covers + // account_move.amount_total, account_move._compute_amount, …) + t.s.starts_with(&needles_iri) + || t.o == dotted + || t.o.starts_with(&format!("odoo:{}", underscored)) + }) + }; + appears(&s1) || appears(&s2) + }; + + let mut missing: Vec<&str> = ODOO_SEED + .iter() + .filter(|r| !in_any_slice(r.odoo_class)) + .map(|r| r.odoo_class) + .collect(); + missing.sort_unstable(); + + // We allow seeded classes to NOT appear in our LIMITED slice corpus — the + // seed reflects the FULL Odoo catalogue, the slices are subsets — but at + // least the load-bearing financial / partner classes (the ones actually + // exercised by W3) MUST appear. + let load_bearing: &[&str] = &[ + "res.partner", + "account.move", + "account.move.line", + "account.account", + "product.template", + ]; + for cls in load_bearing { + assert!( + in_any_slice(cls), + "load-bearing seeded class `{cls}` does not appear in any shipped \ + slice — the seed has drifted from the corpus" + ); + } + // Informational: which seeded classes are NOT in the slice corpus (HR + // classes, hr.*, etc. — they're in the seed for the full-corpus consumer, + // not in our slices). + eprintln!( + "informational: {} of {} seeded classes are not in the shipped slice \ + corpora (full-corpus consumers see them): {:?}", + missing.len(), + ODOO_SEED.len(), + missing + ); +} + +// ── §2: family bytes match the documented registry ───────────────────────── + +#[test] +fn family_bytes_match_documented_registry() { + // From lance-graph data/family_registry.ttl, Option B binding. + assert_eq!(FAMILY_BILLING_CORE.raw(), 0x61, "BillingCore = 97"); + assert_eq!(FAMILY_SMB_ACCOUNTING.raw(), 0x62, "SMBAccounting = 98"); + assert_eq!( + FAMILY_PRODUCT_CATALOG.raw(), + 0x64, + "ProductCatalog = 100 (NOT 0x63 = MRORepair)" + ); + assert_eq!( + FAMILY_SMB_FOUNDRY_CUSTOMER.raw(), + 0x80, + "SmbFoundryCustomer = 128" + ); + assert_eq!( + FAMILY_SMB_FOUNDRY_INVOICE.raw(), + 0x81, + "SmbFoundryInvoice = 129" + ); + assert_eq!(FAMILY_HR_FOUNDATION.raw(), 0x90, "HRFoundation = 144"); +} + +#[test] +fn no_seed_row_uses_an_unregistered_family() { + const REGISTERED: &[u8] = &[0x61, 0x62, 0x64, 0x80, 0x81, 0x90]; + for row in ODOO_SEED { + assert!( + REGISTERED.contains(&row.family.raw()), + "seed row `{}` uses an unregistered family byte 0x{:02X}", + row.odoo_class, + row.family.raw() + ); + } +} + +// ── §3: pivot URIs respect the documented namespace conventions ───────────── + +#[test] +fn every_seed_pivot_uri_is_in_a_recognised_namespace() { + const NAMESPACES: &[&str] = &["fibo:", "schema:", "qudt:", "vcard:", "org:"]; + for row in ODOO_SEED { + assert!( + NAMESPACES.iter().any(|ns| row.pivot_uri.starts_with(ns)), + "seed row `{}` has pivot_uri `{}` outside the recognised \ + namespaces (fibo/schema/qudt/vcard/org)", + row.odoo_class, + row.pivot_uri + ); + } +} + +#[test] +fn pivot_namespace_and_family_are_consistent() { + // Soft consistency: customers land on SmbFoundryCustomer, transactions on + // SmbFoundryInvoice, accounts on SMBAccounting, billable goods on + // BillingCore, catalogue structure on ProductCatalog, HR data on + // HRFoundation. Catches a seed row that puts e.g. a vcard pivot on + // BillingCore. + for row in ODOO_SEED { + let ok = match (row.family, row.pivot_uri) { + (FAMILY_SMB_FOUNDRY_CUSTOMER, p) => p.starts_with("fibo:"), + (FAMILY_SMB_FOUNDRY_INVOICE, p) => p.starts_with("fibo:"), + (FAMILY_SMB_ACCOUNTING, p) => p.starts_with("fibo:"), + (FAMILY_BILLING_CORE, p) => p.starts_with("schema:"), + (FAMILY_PRODUCT_CATALOG, p) => p.starts_with("schema:") || p.starts_with("qudt:"), + (FAMILY_HR_FOUNDATION, p) => { + p.starts_with("vcard:") || p.starts_with("org:") || p.starts_with("fibo:") + } + _ => true, + }; + assert!( + ok, + "seed `{}` mismatch: pivot `{}` on family 0x{:02X}", + row.odoo_class, + row.pivot_uri, + row.family.raw() + ); + } +} + +// ── §4: DOLCE classifier honors the documented suffix rules ──────────────── + +#[test] +fn dolce_classifier_perdurant_suffix_rules() { + assert_eq!(dolce_odoo("account.move"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("account.move.line"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("account.payment"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("sale.order"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("sale.order.line"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("stock.move"), DolceMarker::Perdurant); + assert_eq!(dolce_odoo("stock.picking"), DolceMarker::Perdurant); +} + +#[test] +fn dolce_classifier_abstract_suffix_rules() { + assert_eq!(dolce_odoo("account.tax"), DolceMarker::Abstract); + assert_eq!(dolce_odoo("account.fiscal.position"), DolceMarker::Abstract); + assert_eq!(dolce_odoo("account.reconcile.model"), DolceMarker::Abstract); + assert_eq!(dolce_odoo("account.payment.term"), DolceMarker::Abstract); +} + +#[test] +fn dolce_classifier_quality_and_endurant_rules() { + assert_eq!(dolce_odoo("uom.uom"), DolceMarker::Quality); + assert_eq!(dolce_odoo("uom.category"), DolceMarker::Quality); + assert_eq!(dolce_odoo("res.partner"), DolceMarker::Endurant); + assert_eq!(dolce_odoo("res.users"), DolceMarker::Endurant); + assert_eq!(dolce_odoo("account.account"), DolceMarker::Endurant); + assert_eq!(dolce_odoo("product.template"), DolceMarker::Endurant); + assert_eq!(dolce_odoo("product.product"), DolceMarker::Endurant); +} + +#[test] +fn dolce_classifier_unknown_for_unmatched() { + assert_eq!(dolce_odoo("ir.cron"), DolceMarker::Unknown); + assert_eq!(dolce_odoo("ir.actions.act_window"), DolceMarker::Unknown); +} + +#[test] +fn every_seed_rows_dolce_matches_its_classifier_assignment() { + // Soft pin: if the seed row's DOLCE marker disagrees with the classifier's + // marker for the same class, one of them is wrong. The seed is authoritative + // (declared in the row); a divergence means the classifier needs the rule. + // Tracks classifier-coverage gaps for the deferred Phase-3 universal + // empowerment push. + let mut gaps: Vec<(&'static str, DolceMarker, DolceMarker)> = Vec::new(); + for row in ODOO_SEED { + let classifier = dolce_odoo(row.odoo_class); + if classifier != row.dolce && classifier != DolceMarker::Unknown { + gaps.push((row.odoo_class, row.dolce, classifier)); + } + } + assert!( + gaps.is_empty(), + "classifier disagrees with the seed (NOT Unknown): {gaps:?}" + ); +} + +// ── §5: resolve_odoo lookup surface ───────────────────────────────────────── + +#[test] +fn resolve_odoo_exact_match_returns_seed_row() { + let p = resolve_odoo("res.partner").expect("seeded"); + assert_eq!(p.pivot_uri, "fibo:LegalEntity"); + assert_eq!(p.family, FAMILY_SMB_FOUNDRY_CUSTOMER); + assert_eq!(p.slot, 1); + assert_eq!(p.dolce, DolceMarker::Endurant); + + let p = resolve_odoo("account.move").expect("seeded"); + assert_eq!(p.pivot_uri, "fibo:Transaction"); + assert_eq!(p.family, FAMILY_SMB_FOUNDRY_INVOICE); + + let p = resolve_odoo("uom.uom").expect("seeded"); + assert_eq!(p.pivot_uri, "qudt:Unit"); + assert_eq!(p.family, FAMILY_PRODUCT_CATALOG); + assert_eq!(p.dolce, DolceMarker::Quality); +} + +#[test] +fn resolve_odoo_unseen_product_subtype_inherits_generic_slot() { + let p = resolve_odoo("product.category").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 +} + +#[test] +fn resolve_odoo_unmapped_returns_none() { + // Per Option B: unmapped class → None is the "needs a Layer-2 alignment + // axiom" signal, NOT a minted family. + assert!(resolve_odoo("stock.move").is_none()); + assert!(resolve_odoo("sale.order").is_none()); + assert!(resolve_odoo("account.reconcile.model").is_none()); +} + +// ── §6: cross-validate with OGAR's OdooPort identity codebook ────────────── + +#[cfg(feature = "ogar-emit")] +#[test] +fn seeded_classes_have_compatible_ogar_identity() { + use ogar_vocab::ports::{OdooPort, PortSpec}; + // Cross-axis identity check. + // + // The two surfaces are **complementary axes** of the same Odoo identity: + // - This crate's alignment table = "which FIBO pivot + family/slot" + // (covers six basins: BillingCore + SMBAccounting + ProductCatalog + + // SmbFoundryCustomer + SmbFoundryInvoice + HRFoundation). + // - OGAR's `OdooPort` aliases = "which canonical OGAR class_id" + // (currently covers the **commerce arm** only — `0x02XX` ids: + // COMMERCIAL_DOCUMENT, COMMERCIAL_LINE_ITEM, TAX_POLICY, + // BILLING_PARTY, PAYMENT_RECORD, CURRENCY_POLICY, plus the + // cross-arm BILLABLE_WORK_ENTRY). + // + // The intersection MUST agree (every commerce-arm seed row has an OdooPort + // classid); the difference (product / accounting / HR seed rows without an + // OdooPort alias) IS the surface for a future OdooPort PR and is reported + // as informational rather than asserted. + let mut without_classid: Vec<&str> = Vec::new(); + for row in ODOO_SEED { + if OdooPort::class_id(row.odoo_class).is_none() { + without_classid.push(row.odoo_class); + } + } + without_classid.sort_unstable(); + eprintln!( + "informational: {} of {} seeded classes have no canonical OGAR \ + classid yet (= candidates for the next OdooPort PR — product / \ + accounting / HR basins): {:?}", + without_classid.len(), + ODOO_SEED.len(), + without_classid + ); + + // HARD assertion: the commerce-arm intersection. These three are in BOTH + // surfaces today; dropping any of them from `OdooPort::aliases()` would + // break the cross-axis identity. + let commerce_arm: &[&str] = &["res.partner", "account.move", "account.move.line"]; + for cls in commerce_arm { + assert!( + OdooPort::class_id(cls).is_some(), + "commerce-arm seeded class `{cls}` has no canonical OGAR classid; \ + OdooPort and the alignment table have drifted apart" + ); + } +}