|
| 1 | +//! # `class_signature` — structural-signature audit of the curated `OdooEntity` consts. |
| 2 | +//! |
| 3 | +//! The honest D-CLS-2/D-CLS-3: classes.md:41-44 says the class taxonomy is |
| 4 | +//! **discovered, not hand-assigned** — "20,000 Odoo entities are NOT 20,000 |
| 5 | +//! shapes; they are instances of ~dozens of shape-families. Group by structural |
| 6 | +//! signature (which fields, `_compute_*` shape, depends/emits pattern)." |
| 7 | +//! |
| 8 | +//! This is the **deterministic** group-by-on-structural-hash (NOT an Aerial+ |
| 9 | +//! clustering pass — `aerial` mines association *rules*, it does not cluster |
| 10 | +//! entities; the brutal-review confirmed that entry point does not exist). Two |
| 11 | +//! entities with the same structural signature ARE the same shape-family. |
| 12 | +//! |
| 13 | +//! It also derives the per-class [`ObjectView`] **field-set** (the bit-basis the |
| 14 | +//! [`crate::class_resolver::RegistryClassView`] previously took as a supplied |
| 15 | +//! placeholder). Field position `i` in the derived `ObjectView` is the stable |
| 16 | +//! [`FieldMask`](lance_graph_contract::class_view::FieldMask) bit `i` (N3). |
| 17 | +//! |
| 18 | +//! Pure analysis over the `&'static` const data — no hot path, no mutation. |
| 19 | +
|
| 20 | +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef, ObjectView}; |
| 21 | + |
| 22 | +use super::{OdooEntity, OdooFieldKind, OdooMethodKind}; |
| 23 | + |
| 24 | +/// A deterministic structural signature of an [`OdooEntity`] — the shape-family key. |
| 25 | +/// |
| 26 | +/// Two entities sharing a `StructuralSignature` are the same shape-family |
| 27 | +/// (classes.md:43). Built from the *structure* (kind + field-kind histogram + |
| 28 | +/// method-kind histogram + state-machine presence), NOT the names — so |
| 29 | +/// `account.move` and `sale.order` (both stateful compute-emitting models) |
| 30 | +/// collapse to one family while a plain config model does not. |
| 31 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 32 | +pub struct StructuralSignature(pub u32); |
| 33 | + |
| 34 | +/// FNV-1a 32-bit over the canonicalized structural tuple. Mirrors the workspace |
| 35 | +/// idiom (`odoo_blueprint::style_recipe::fnv1a_recipe`). Collisions are intentional |
| 36 | +/// — identical structure → identical family id. |
| 37 | +fn fnv1a(bytes: &[u8]) -> u32 { |
| 38 | + const OFFSET: u32 = 0x811c_9dc5; |
| 39 | + const PRIME: u32 = 0x0100_0193; |
| 40 | + let mut h = OFFSET; |
| 41 | + for b in bytes { |
| 42 | + h ^= u32::from(*b); |
| 43 | + h = h.wrapping_mul(PRIME); |
| 44 | + } |
| 45 | + h |
| 46 | +} |
| 47 | + |
| 48 | +/// The 6 [`OdooFieldKind`] buckets the histogram counts (the closed structural axis). |
| 49 | +fn field_kind_bucket(k: OdooFieldKind) -> usize { |
| 50 | + match k { |
| 51 | + OdooFieldKind::Char | OdooFieldKind::Text | OdooFieldKind::Html => 0, // textual |
| 52 | + OdooFieldKind::Integer | OdooFieldKind::Float | OdooFieldKind::Monetary => 1, // numeric |
| 53 | + OdooFieldKind::Boolean => 2, |
| 54 | + OdooFieldKind::Date | OdooFieldKind::Datetime => 3, // temporal |
| 55 | + OdooFieldKind::Selection => 4, |
| 56 | + // relational + the rest (Many2one/One2many/Many2many/Binary/…) |
| 57 | + _ => 5, |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +/// The 5 [`OdooMethodKind`] buckets the histogram counts (the `_compute_*` shape axis). |
| 62 | +fn method_kind_bucket(k: OdooMethodKind) -> usize { |
| 63 | + match k { |
| 64 | + OdooMethodKind::Compute | OdooMethodKind::Inverse => 0, // computed-value shape |
| 65 | + OdooMethodKind::Constrain => 1, // validation shape |
| 66 | + OdooMethodKind::Onchange => 2, // reactive-UI shape |
| 67 | + OdooMethodKind::Action => 3, // state-transition shape |
| 68 | + _ => 4, // cron/api/override/helper |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +/// Compute the [`StructuralSignature`] of an entity (deterministic, name-independent). |
| 73 | +pub fn signature(entity: &OdooEntity) -> StructuralSignature { |
| 74 | + // Canonical tuple: [kind_disc, field_hist x6, method_hist x5, has_state_machine]. |
| 75 | + // Counts are saturated to u8 so the byte layout is stable + the hash deterministic. |
| 76 | + let mut buf = [0u8; 1 + 6 + 5 + 1]; |
| 77 | + buf[0] = entity.kind as u8; |
| 78 | + |
| 79 | + let mut field_hist = [0u32; 6]; |
| 80 | + for f in entity.fields { |
| 81 | + field_hist[field_kind_bucket(f.kind)] += 1; |
| 82 | + } |
| 83 | + for (i, c) in field_hist.iter().enumerate() { |
| 84 | + buf[1 + i] = (*c).min(255) as u8; |
| 85 | + } |
| 86 | + |
| 87 | + let mut method_hist = [0u32; 5]; |
| 88 | + for m in entity.methods { |
| 89 | + method_hist[method_kind_bucket(m.kind)] += 1; |
| 90 | + } |
| 91 | + for (i, c) in method_hist.iter().enumerate() { |
| 92 | + buf[7 + i] = (*c).min(255) as u8; |
| 93 | + } |
| 94 | + |
| 95 | + buf[12] = u8::from(entity.state_machine.is_some()); |
| 96 | + |
| 97 | + StructuralSignature(fnv1a(&buf)) |
| 98 | +} |
| 99 | + |
| 100 | +/// Derive the per-class [`ObjectView`] **field-set** from an entity's declared |
| 101 | +/// fields — the real bit-basis. Field position `i` here is the stable |
| 102 | +/// [`FieldMask`](lance_graph_contract::class_view::FieldMask) bit `i` (N3). |
| 103 | +/// |
| 104 | +/// Field order = declaration order (append-only stability: new fields append at |
| 105 | +/// higher positions, existing positions never move). The first textual/`Char` |
| 106 | +/// field becomes the `primary_label`; the `DisplayTemplate` is chosen by size |
| 107 | +/// (`<= 4` fields → `Card`, else `Detail`). Capped at |
| 108 | +/// [`FieldMask::MAX_FIELDS`](lance_graph_contract::class_view::FieldMask::MAX_FIELDS) |
| 109 | +/// (64) — the mask cannot address beyond a `u64`. |
| 110 | +pub fn object_view(entity: &OdooEntity) -> ObjectView { |
| 111 | + use lance_graph_contract::class_view::FieldMask; |
| 112 | + |
| 113 | + let cap = FieldMask::MAX_FIELDS as usize; |
| 114 | + let fields: Vec<FieldRef> = entity |
| 115 | + .fields |
| 116 | + .iter() |
| 117 | + .take(cap) |
| 118 | + .map(|f| FieldRef::new(f.name, f.name)) // label defaults to name; OGIT resolves the display label late |
| 119 | + .collect(); |
| 120 | + |
| 121 | + let template = if fields.len() <= 4 { |
| 122 | + DisplayTemplate::Card |
| 123 | + } else { |
| 124 | + DisplayTemplate::Detail |
| 125 | + }; |
| 126 | + |
| 127 | + let mut view = ObjectView::new(template, fields); |
| 128 | + // primary_label = the first textual field (the headline), if any. |
| 129 | + view.primary_label = entity |
| 130 | + .fields |
| 131 | + .iter() |
| 132 | + .find(|f| field_kind_bucket(f.kind) == 0) |
| 133 | + .map(|f| f.name.to_string()); |
| 134 | + view |
| 135 | +} |
| 136 | + |
| 137 | +/// One audited entity row: its name, ORM kind, derived signature, field count. |
| 138 | +#[derive(Debug, Clone)] |
| 139 | +pub struct AuditRow { |
| 140 | + pub model_name: &'static str, |
| 141 | + pub signature: StructuralSignature, |
| 142 | + pub field_count: usize, |
| 143 | + pub method_count: usize, |
| 144 | + pub has_state_machine: bool, |
| 145 | +} |
| 146 | + |
| 147 | +/// Audit a slice of entities → their rows (read-only structural pass). |
| 148 | +pub fn audit(entities: &[OdooEntity]) -> Vec<AuditRow> { |
| 149 | + entities |
| 150 | + .iter() |
| 151 | + .map(|e| AuditRow { |
| 152 | + model_name: e.model_name, |
| 153 | + signature: signature(e), |
| 154 | + field_count: e.fields.len(), |
| 155 | + method_count: e.methods.len(), |
| 156 | + has_state_machine: e.state_machine.is_some(), |
| 157 | + }) |
| 158 | + .collect() |
| 159 | +} |
| 160 | + |
| 161 | +/// Group audited entities into shape-families by structural signature |
| 162 | +/// (the discovered taxonomy, classes.md:43). Returns `(signature → member model |
| 163 | +/// names)`, sorted by signature for deterministic output. |
| 164 | +pub fn shape_families(entities: &[OdooEntity]) -> Vec<(StructuralSignature, Vec<&'static str>)> { |
| 165 | + use std::collections::BTreeMap; |
| 166 | + let mut families: BTreeMap<u32, Vec<&'static str>> = BTreeMap::new(); |
| 167 | + for e in entities { |
| 168 | + families |
| 169 | + .entry(signature(e).0) |
| 170 | + .or_default() |
| 171 | + .push(e.model_name); |
| 172 | + } |
| 173 | + families |
| 174 | + .into_iter() |
| 175 | + .map(|(sig, members)| (StructuralSignature(sig), members)) |
| 176 | + .collect() |
| 177 | +} |
| 178 | + |
| 179 | +#[cfg(test)] |
| 180 | +mod tests { |
| 181 | + use super::*; |
| 182 | + use crate::odoo_blueprint::l1; |
| 183 | + |
| 184 | + #[test] |
| 185 | + fn signature_is_deterministic_and_name_independent() { |
| 186 | + // Same entity → same signature on repeat (deterministic). |
| 187 | + let a = signature(&l1::ACCOUNT_MOVE); |
| 188 | + let b = signature(&l1::ACCOUNT_MOVE); |
| 189 | + assert_eq!(a, b, "signature must be deterministic"); |
| 190 | + // account.move and account.move.line have DIFFERENT structure (one is a |
| 191 | + // stateful header, the other a line) → different signatures. |
| 192 | + assert_ne!( |
| 193 | + signature(&l1::ACCOUNT_MOVE), |
| 194 | + signature(&l1::ACCOUNT_MOVE_LINE), |
| 195 | + "structurally different entities must not collide" |
| 196 | + ); |
| 197 | + } |
| 198 | + |
| 199 | + #[test] |
| 200 | + fn object_view_derives_the_bit_basis_from_fields() { |
| 201 | + let v = object_view(&l1::ACCOUNT_MOVE); |
| 202 | + // Field count = the entity's declared fields (capped at 64), in order. |
| 203 | + assert_eq!(v.fields.len(), l1::ACCOUNT_MOVE.fields.len().min(64)); |
| 204 | + // Position 0 is the first declared field (stable bit 0). |
| 205 | + assert_eq!(v.fields[0].predicate_iri, l1::ACCOUNT_MOVE.fields[0].name); |
| 206 | + // ACCOUNT_MOVE is stateful + field-rich → Detail template. |
| 207 | + assert_eq!(v.display_template, DisplayTemplate::Detail); |
| 208 | + } |
| 209 | + |
| 210 | + #[test] |
| 211 | + fn shape_families_group_deterministically() { |
| 212 | + let families = shape_families(l1::ENTITIES); |
| 213 | + // Every l1 entity is accounted for exactly once across families. |
| 214 | + let total: usize = families.iter().map(|(_, m)| m.len()).sum(); |
| 215 | + assert_eq!(total, l1::ENTITIES.len()); |
| 216 | + // Output is sorted by signature (deterministic). |
| 217 | + let sigs: Vec<u32> = families.iter().map(|(s, _)| s.0).collect(); |
| 218 | + let mut sorted = sigs.clone(); |
| 219 | + sorted.sort_unstable(); |
| 220 | + assert_eq!(sigs, sorted, "families sorted by signature"); |
| 221 | + } |
| 222 | + |
| 223 | + #[test] |
| 224 | + fn audit_row_count_matches_input() { |
| 225 | + let rows = audit(l1::ENTITIES); |
| 226 | + assert_eq!(rows.len(), l1::ENTITIES.len()); |
| 227 | + assert!(rows.iter().all(|r| !r.model_name.is_empty())); |
| 228 | + } |
| 229 | +} |
0 commit comments