Skip to content

Commit 438e9d7

Browse files
committed
feat(ontology): D-CLS-SIG — class_signature (deterministic structural-hash shape-family audit; fills the D-CLS-RES bit-basis)
The honest D-CLS-2/3: classes.md:43 discovered-taxonomy via deterministic group-by-on-structural-hash (NOT aerial-cluster vaporware — aerial mines rules not clusters, brutal-review confirmed). signature(&OdooEntity)->u32 FNV-1a over [kind, field-kind-hist x6, method-kind-hist x5, has_state_machine] (mirrors style_recipe::fnv1a_recipe), deterministic + name-independent. object_view(&OdooEntity) derives the real ObjectView field-set (field i = stable FieldMask bit i, N3) -> FILLS the supplied-placeholder class_resolver (D-CLS-RES) took, so the slices compose. shape_families + audit. 4 teeth-tests over real l1::ENTITIES; 238 ontology lib green; clippy+fmt clean. https://claude.ai/code/session_01R9AWgFa65uPnLyS2my2d2R
1 parent 69531ba commit 438e9d7

4 files changed

Lines changed: 245 additions & 0 deletions

File tree

.claude/board/EPIPHANIES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
## 2026-05-31 — SHIPPED-in-PR: D-CLS-SIG — class_signature (the HONEST discovered-taxonomy; structural-hash group-by, not aerial-cluster vaporware)
2+
3+
**Status:** SHIPPED-in-PR (#440 D-CLS). The corrected D-CLS-2 + D-CLS-3 — replaces the brutal-review-killed "Aerial+ clusters entities" vaporware with the deterministic group-by classes.md:43 actually prescribes ("group-by-on-structural-hash OR Aerial+").
4+
5+
`lance-graph-ontology::odoo_blueprint::class_signature` (pure const analysis, no hot path):
6+
- **`signature(&OdooEntity) -> StructuralSignature(u32)`** — FNV-1a (mirrors the workspace `style_recipe::fnv1a_recipe` idiom) over the canonicalized structural tuple: `[kind_disc, field-kind-histogram x6, method-kind-histogram x5, has_state_machine]`. Deterministic + NAME-INDEPENDENT (groups by structure, not model name). Two entities with the same signature ARE the same shape-family (classes.md:43 discovered taxonomy).
7+
- **`object_view(&OdooEntity) -> ObjectView`** — derives the per-class field-SET (the real FieldMask bit-basis): field position i = declared-field i = stable bit i (N3 append-only); primary_label = first textual field; template by size (<=4 Card else Detail); capped at FieldMask::MAX_FIELDS(64). **This FILLS the supplied-placeholder the class_resolver (D-CLS-RES) took** — the two slices now compose: signature→family, object_view→the bit-basis RegistryClassView consumes.
8+
- **`shape_families(&[OdooEntity]) -> Vec<(sig, members)>`** (BTreeMap-sorted deterministic) + **`audit(...)`** rows.
9+
10+
**Honesty (brutal-review corrections folded in):** deterministic group-by, NOT aerial-cluster (aerial mines RULES not entity-clusters — confirmed no clustering entry point); scoped to the curated l-lane consts (not the false-66); FNV collisions are intentional (same structure→same family). 4 teeth-tests over REAL l1::ENTITIES (determinism, name-independence, bit-basis derivation, group-completeness). 238 ontology lib green; clippy+fmt clean.
11+
12+
**Composes the D-CLS arc:** D-CLS-FM (contract FieldMask+ClassView) ← D-CLS-RES (ontology resolver over the cache) ← D-CLS-SIG (the field-set + shape-families the resolver needs). Next: run the audit over all curated l-lanes → name ~10-15 families (human/spec-owner step); the askama render crate consuming project(); the registry by_entity_type_id O(1) index.
13+
14+
**Cross-ref:** D-CLS-FM/D-CLS-RES (the arc this completes the input for); #440 plan §D-CLS-2/3; brutal-review verdict (aerial-cluster→group-by correction); classes.md:41-44 (discovered taxonomy) + :48 (delta bitmask); `style_recipe::fnv1a_recipe` (the hash idiom reused).
115
## 2026-05-31 — SHIPPED-in-PR: D-CLS-RES — class_resolver (ontology-side impl ClassView; the meta-DTO flies over the LIVE OGIT cache)
216

317
**Status:** SHIPPED-in-PR (#440 D-CLS). Makes the contract `ClassView` trait LIVE — the "OGIT hashtable single-lookup → class meta-lookup" upgrade, done.

.claude/board/STATUS_BOARD.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ Plan path: `.claude/plans/unified-soa-convergence-v1.md`. Handover `.claude/hand
570570
| D-MBX-A6-P3-M1 | `Tactic::requires() -> ThoughtMask` + `ThoughtField`/`ThoughtMask` (checklist-as-data keystone): 34 tactics declare their ThoughtCtx field-reads; `covered_by` = reliability-coverage gate | lance-graph-contract | 120 | LOW | **In PR** | #439; the panel-recalibrated keystone (extraction not construction); makes P1/P7/P11 derived; teeth-test asserts masks varied not stub |
571571
| D-CLS-FM | `class_view`: FieldMask(u64 presence) + ClassView meta-DTO resolver trait + ClassProjection (the class flies ABOVE the SoA; labels resolved late from OGIT cache, zero in the bytes) — extends ObjectView, reuses class_id | lance-graph-contract | 270 | LOW | **In PR** | #440 D-CLS contract foundation; OD-gates ratified; presence!=semantics (C2); N3 stable positions; 3 teeth-tests |
572572
| D-CLS-RES | `class_resolver`: `RegistryClassView` impls `ClassView` over the live OntologyRegistry — the ontology-side 'parser' (class_id -> shape, DOLCE resolved LATE via classify_odoo from the cache URI, memoized over the O(n) registry scan) | lance-graph-ontology | 200 | LOW | **In PR** | #440 D-CLS; makes the contract trait live; field-set supplied (D-CLS audit deferred); 4 teeth-tests |
573+
| D-CLS-SIG | `class_signature`: deterministic structural-signature audit of curated OdooEntity consts (FNV-1a over kind+field-hist+method-hist+state-machine) -> shape-family group-by + `object_view()` derives the real ObjectView bit-basis (fills the D-CLS-RES placeholder) | lance-graph-ontology | 230 | LOW | **In PR** | #440 D-CLS; the HONEST D-CLS-3 (group-by-on-structural-hash, NOT aerial-cluster vaporware, classes.md:43); 4 teeth-tests over real l1 data |
573574

574575
---
575576

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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+
}

crates/lance-graph-ontology/src/odoo_blueprint/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
// lane. Stubs land first (additive, all empty); the projection wave
5353
// populates each from the L-doc prose.
5454

55+
pub mod class_signature;
5556
pub mod l1;
5657
pub mod l2;
5758
pub mod l3;

0 commit comments

Comments
 (0)