|
| 1 | +//! Odoo DOLCE suffix classifier — Seam decision 2, in its own module. |
| 2 | +//! |
| 3 | +//! Per Open-question 3 of the four-way alignment seam |
| 4 | +//! (`woa-rs/.claude/reference/four_way_alignment_seam.md`), the odoo-specific |
| 5 | +//! DOLCE heuristics live in a separate module rather than inline in |
| 6 | +//! `dolce.rs`, so each extraction source (odoo, FMA, SNOMED, future ones) owns |
| 7 | +//! its own per-source heuristic logic. |
| 8 | +//! |
| 9 | +//! The odoo namespace uses dotted lowercase model names (`account.move`, |
| 10 | +//! `stock.move`, `hr.attendance`) where event semantics are encoded by |
| 11 | +//! *suffix* (`.move`, `.message`, `.attendance`). [`classify_odoo`] maps a |
| 12 | +//! model name onto its DOLCE upper category from those suffixes, with one |
| 13 | +//! explicit special-case (`product.template` — odoo's "template" there means |
| 14 | +//! the master product record, an Endurant, not a config template). |
| 15 | +//! |
| 16 | +//! Litmus (CLAUDE.md): this is a stateless pure function with no carrier — it |
| 17 | +//! reads a `&str` and returns a category. That is the sanctioned shape for a |
| 18 | +//! classifier; there is no odoo-class struct to hang it on. |
| 19 | +
|
| 20 | +/// DOLCE upper categories used by the odoo suffix classifier. |
| 21 | +/// |
| 22 | +/// These are the four DOLCE-Lite-Plus top categories. **Canonical DOLCE+DUL |
| 23 | +/// renames `Endurant` → `Object` and `Perdurant` → `Event`** per the DUL |
| 24 | +/// ontology header (see `dolce.rs` module docs); this enum keeps the original |
| 25 | +/// DOLCE-Lite-Plus names because that is the vocabulary the seam doc's test |
| 26 | +/// matrix and the `lance-graph-callcenter::super_domain::DolceMarker` seed use. |
| 27 | +/// |
| 28 | +/// Unlike `DolceMarker` in `lance-graph-callcenter`, there is no `Unknown` |
| 29 | +/// variant: [`classify_odoo`] always returns a concrete category (defaulting |
| 30 | +/// to [`DolceCategory::Endurant`] for persistent stateful objects), matching |
| 31 | +/// the seam's "Default: Endurant" rule. |
| 32 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 33 | +pub enum DolceCategory { |
| 34 | + /// Persistent stateful object (DUL: `Object`). The default. |
| 35 | + Endurant, |
| 36 | + /// Event / occurrence that unfolds in time (DUL: `Event`). |
| 37 | + Perdurant, |
| 38 | + /// An attribute / rate / classification that characterises something. |
| 39 | + Quality, |
| 40 | + /// A reference / configuration / template — an abstract entity. |
| 41 | + AbstractEntity, |
| 42 | +} |
| 43 | + |
| 44 | +/// Name suffixes indicating a Perdurant (event / occurrence). |
| 45 | +/// |
| 46 | +/// `.move.line` precedes `.move` in spirit — a line within a move event is a |
| 47 | +/// fact within that Perdurant (seam matrix: `account.move.line → Perdurant`). |
| 48 | +/// It is listed explicitly because it does not end with `.move`. Order within |
| 49 | +/// this list does not matter (any match returns Perdurant), but the comment |
| 50 | +/// records why both are present. |
| 51 | +const PERDURANT_SUFFIXES: &[&str] = &[ |
| 52 | + ".move.line", // account.move.line — fact within the move event |
| 53 | + ".move", // account.move, stock.move, hr.leave.allocation.move |
| 54 | + ".message", // mail.message |
| 55 | + ".activity", // mail.activity |
| 56 | + ".attendance", // hr.attendance |
| 57 | + ".transition", // workflow transitions |
| 58 | + ".event", // calendar.event |
| 59 | + ".log", // any .log model |
| 60 | + ".history", // change history |
| 61 | + ".transaction", // payment.transaction |
| 62 | + ".picking", // stock.picking (logistic event) |
| 63 | + ".scrap", // stock.scrap (logistic event) |
| 64 | +]; |
| 65 | + |
| 66 | +/// Name suffixes indicating a Quality (attribute / classification / rate). |
| 67 | +const QUALITY_SUFFIXES: &[&str] = &[ |
| 68 | + ".tag", // crm.tag, account.account.tag |
| 69 | + ".category", // product.category, res.partner.category |
| 70 | + ".type", // account.account.type, sale.order.type |
| 71 | + ".group", // res.groups, account.tax.group |
| 72 | + ".tax", // account.tax (it's a rate, a quality, not an event) |
| 73 | +]; |
| 74 | + |
| 75 | +/// Name suffixes indicating an AbstractEntity (reference / config / template). |
| 76 | +const ABSTRACT_SUFFIXES: &[&str] = &[ |
| 77 | + ".template", // mail.template, account.chart.template |
| 78 | + ".config", // *.config.settings |
| 79 | + ".policy", // any *.policy |
| 80 | + ".rule", // account.reconcile.model rules |
| 81 | + ".formula", // hr.payroll.structure.line formulas |
| 82 | +]; |
| 83 | + |
| 84 | +/// Classify an odoo model IRI / name onto its DOLCE upper category. |
| 85 | +/// |
| 86 | +/// Accepts either a bare model name (`"res.partner"`) or a prefixed IRI |
| 87 | +/// (`"odoo:res.partner"` / `"https://ada.world/onto/odoo#res.partner"`); the |
| 88 | +/// prefix is stripped to the model name before matching. |
| 89 | +/// |
| 90 | +/// Resolution order (first match wins): |
| 91 | +/// 1. `product.template` special-case → [`DolceCategory::Endurant`] (odoo uses |
| 92 | +/// "template" here for the master product record, not a config template). |
| 93 | +/// 2. Perdurant suffix → [`DolceCategory::Perdurant`]. |
| 94 | +/// 3. Quality suffix → [`DolceCategory::Quality`]. |
| 95 | +/// 4. Abstract suffix → [`DolceCategory::AbstractEntity`]. |
| 96 | +/// 5. Default → [`DolceCategory::Endurant`] (persistent stateful object). |
| 97 | +pub fn classify_odoo(iri: &str) -> DolceCategory { |
| 98 | + let model = model_name(iri); |
| 99 | + |
| 100 | + // (1) The single special-case: product.template is a master record |
| 101 | + // (Endurant), NOT an abstract config template — even though `.template` |
| 102 | + // is an Abstract suffix below. Must be checked before the suffix lists. |
| 103 | + if model == "product.template" { |
| 104 | + return DolceCategory::Endurant; |
| 105 | + } |
| 106 | + |
| 107 | + // (2) Perdurant — event / occurrence by suffix. |
| 108 | + for suffix in PERDURANT_SUFFIXES { |
| 109 | + if model.ends_with(suffix) { |
| 110 | + return DolceCategory::Perdurant; |
| 111 | + } |
| 112 | + } |
| 113 | + // (3) Quality — attribute / classification / rate by suffix. |
| 114 | + for suffix in QUALITY_SUFFIXES { |
| 115 | + if model.ends_with(suffix) { |
| 116 | + return DolceCategory::Quality; |
| 117 | + } |
| 118 | + } |
| 119 | + // (4) AbstractEntity — reference / config / template by suffix. |
| 120 | + for suffix in ABSTRACT_SUFFIXES { |
| 121 | + if model.ends_with(suffix) { |
| 122 | + return DolceCategory::AbstractEntity; |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // (5) Default: Endurant (res.partner, res.users, res.company, |
| 127 | + // product.product, account.account, account.journal, stock.warehouse, |
| 128 | + // crm.lead, hr.employee, …). |
| 129 | + DolceCategory::Endurant |
| 130 | +} |
| 131 | + |
| 132 | +/// Strip a leading `odoo:` prefix or the full odoo namespace IRI, returning the |
| 133 | +/// bare odoo model name. |
| 134 | +fn model_name(iri: &str) -> &str { |
| 135 | + if let Some(rest) = iri.strip_prefix("https://ada.world/onto/odoo#") { |
| 136 | + return rest; |
| 137 | + } |
| 138 | + iri.trim_start_matches("odoo:") |
| 139 | +} |
| 140 | + |
| 141 | +#[cfg(test)] |
| 142 | +mod tests { |
| 143 | + use super::*; |
| 144 | + |
| 145 | + #[test] |
| 146 | + fn strips_iri_prefixes() { |
| 147 | + assert_eq!(model_name("odoo:res.partner"), "res.partner"); |
| 148 | + assert_eq!( |
| 149 | + model_name("https://ada.world/onto/odoo#account.move"), |
| 150 | + "account.move" |
| 151 | + ); |
| 152 | + assert_eq!(model_name("res.partner"), "res.partner"); |
| 153 | + } |
| 154 | + |
| 155 | + #[test] |
| 156 | + fn classifier_handles_prefixed_iris() { |
| 157 | + assert_eq!(classify_odoo("odoo:account.move"), DolceCategory::Perdurant); |
| 158 | + assert_eq!( |
| 159 | + classify_odoo("https://ada.world/onto/odoo#res.partner"), |
| 160 | + DolceCategory::Endurant |
| 161 | + ); |
| 162 | + } |
| 163 | +} |
0 commit comments