|
| 1 | +//! # `class_view` — the class as a META lookup that flies ABOVE the SoA. |
| 2 | +//! |
| 3 | +//! ## The XML-parse framing |
| 4 | +//! |
| 5 | +//! Today OGIT (`lance-graph-ontology::OntologyRegistry`) is a **hashtable doing |
| 6 | +//! single lookups**: `uri → row`, `entity_type_id → row` — one key, one value, |
| 7 | +//! O(1), leaf. That is the *single* lookup. What a class needs is a **meta |
| 8 | +//! lookup**: `class_id → the whole shape` (ordered field set + labels + template |
| 9 | +//! + the presence-bit basis). The class composes many leaf lookups into one |
| 10 | +//! shape — the way an XSD schema composes element declarations. |
| 11 | +//! |
| 12 | +//! ```text |
| 13 | +//! SoA row = the XML document (agnostic bytes, no meaning) |
| 14 | +//! class / ObjectView = the XSD schema (the shape: which fields, in order) |
| 15 | +//! ClassView (this) = the parser+schema (projects row → typed view, late-bound) |
| 16 | +//! FieldMask = which optional elements are present (structural) |
| 17 | +//! askama template = the XSLT (renders the projected view) |
| 18 | +//! ``` |
| 19 | +//! |
| 20 | +//! ## Classes fly as a meta-DTO ABOVE the SoA — the SoA stays agnostic |
| 21 | +//! |
| 22 | +//! The load-bearing rule (`cognitive-risc-classes.md`:39 "the meta-DTO resolves; |
| 23 | +//! it does not store"; `cognitive-risc-core.md` invariant #1 "nothing semantic in |
| 24 | +//! the register file"): the SoA row carries **only** `class_id` + a presence |
| 25 | +//! [`FieldMask`] + agnostic columns. **Zero labels in the bytes.** The |
| 26 | +//! labels / template / DOLCE-category are resolved *at projection time* by the |
| 27 | +//! flying meta-DTO from the OGIT cache — never hand-rolled onto the row. |
| 28 | +//! |
| 29 | +//! That makes the presence/semantics split (C2) fall out for free: |
| 30 | +//! - **bit = presence** — structural, lives on the SoA ("field N is populated"). |
| 31 | +//! - **bit → field → label → template** — semantic resolution, lives in the |
| 32 | +//! meta-DTO *above* the SoA. A bit NEVER means "field N behaves differently." |
| 33 | +//! |
| 34 | +//! ## Layering (dependency inversion, same shape as `MailboxSoaView`) |
| 35 | +//! |
| 36 | +//! - **contract (here, zero-dep):** the agnostic surface — [`FieldMask`] presence |
| 37 | +//! bits + the [`ClassView`] resolver *trait*. Extends the existing |
| 38 | +//! [`crate::ontology::ObjectView`] (the per-class ordered field set = the bit |
| 39 | +//! basis), does not duplicate it. |
| 40 | +//! - **ontology (one layer up):** *implements* [`ClassView`] — the "parser" that |
| 41 | +//! walks the class shape and resolves labels late from the OGIT hashmap. |
| 42 | +//! - **render (a consumer):** reads the projected view + mask, picks the askama |
| 43 | +//! template, skips off-bits. |
| 44 | +
|
| 45 | +use crate::ontology::{DisplayTemplate, FieldRef}; |
| 46 | + |
| 47 | +/// Per-row class discriminator — the Cognitive-RISC `class_id` / `shape_id`. |
| 48 | +/// |
| 49 | +/// A `u16` (≤ 65,535 shape-families; OD-CLASSID-WIDTH ratified). It is a |
| 50 | +/// *discriminator*, never a content hash — it stays OUTSIDE the CAM identity |
| 51 | +/// layer (`I-VSA-IDENTITIES`: never hashed-as-content, never superposed). Reuses |
| 52 | +/// the width of the existing [`crate::soa_view::MailboxSoaView::class_id`] accessor. |
| 53 | +pub type ClassId = u16; |
| 54 | + |
| 55 | +/// A class's **presence bitmask** — one bit per field of its class |
| 56 | +/// [`ObjectView`](crate::ontology::ObjectView), set iff that field is populated |
| 57 | +/// on a given instance. |
| 58 | +/// |
| 59 | +/// The instance's *delta from its class* (`cognitive-risc-classes.md`:48), as |
| 60 | +/// **pure presence bits**. Bit position `N` = the `N`-th field in the class's |
| 61 | +/// ordered field list — stable + append-only (N3): once instances persist, a |
| 62 | +/// field's bit position never moves and retired bits are never reused. Zero-dep |
| 63 | +/// (`u64`, no `bitflags`); mask width is bounded by the *class's* field count |
| 64 | +/// (dozens), never the entity union. |
| 65 | +/// |
| 66 | +/// **Presence, NEVER semantics (C2).** `has(n)` answers "is field n populated |
| 67 | +/// here"; it must never gate "field n means something different here." |
| 68 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] |
| 69 | +pub struct FieldMask(pub u64); |
| 70 | + |
| 71 | +impl FieldMask { |
| 72 | + /// The empty mask (no fields populated). |
| 73 | + pub const EMPTY: Self = Self(0); |
| 74 | + |
| 75 | + /// Maximum addressable field positions in one `u64` mask. |
| 76 | + pub const MAX_FIELDS: u32 = 64; |
| 77 | + |
| 78 | + /// Build a mask from the populated field positions. |
| 79 | + pub const fn from_positions(positions: &[u8]) -> Self { |
| 80 | + let mut bits = 0u64; |
| 81 | + let mut i = 0; |
| 82 | + while i < positions.len() { |
| 83 | + bits |= 1u64 << (positions[i] as u64 & 63); |
| 84 | + i += 1; |
| 85 | + } |
| 86 | + Self(bits) |
| 87 | + } |
| 88 | + |
| 89 | + /// Set field position `n` as populated. |
| 90 | + #[inline] |
| 91 | + pub const fn with(self, n: u8) -> Self { |
| 92 | + Self(self.0 | (1u64 << (n as u64 & 63))) |
| 93 | + } |
| 94 | + |
| 95 | + /// Is field position `n` populated? (presence — C2) |
| 96 | + #[inline] |
| 97 | + pub const fn has(self, n: u8) -> bool { |
| 98 | + self.0 & (1u64 << (n as u64 & 63)) != 0 |
| 99 | + } |
| 100 | + |
| 101 | + /// Number of populated fields. |
| 102 | + #[inline] |
| 103 | + pub const fn count(self) -> u32 { |
| 104 | + self.0.count_ones() |
| 105 | + } |
| 106 | + |
| 107 | + /// Is nothing populated? |
| 108 | + #[inline] |
| 109 | + pub const fn is_empty(self) -> bool { |
| 110 | + self.0 == 0 |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +/// The class as a **meta lookup that flies above the SoA** — the resolver trait. |
| 115 | +/// |
| 116 | +/// An implementor (in `lance-graph-ontology`, over the OGIT cache) is the |
| 117 | +/// "parser+schema": given a `class_id` it resolves the class's ordered field set, |
| 118 | +/// labels, DOLCE category, and render template — all LATE-bound from the cache, |
| 119 | +/// none stored on the SoA row. The contract owns only the *vocabulary*; the cache |
| 120 | +/// owns the *answers* (dependency inversion, like `PlannerContract`/`MailboxSoaView`). |
| 121 | +/// |
| 122 | +/// "Single lookup" (leaf, today) vs "meta lookup" (the class, this trait): a |
| 123 | +/// single lookup is `uri → row`; a meta lookup is `class_id → shape`, composing |
| 124 | +/// many leaf lookups into one projected view. |
| 125 | +pub trait ClassView { |
| 126 | + /// The class's ordered field set — the bit basis. Position `i` in this slice |
| 127 | + /// is the stable [`FieldMask`] bit `i` (N3 append-only). This IS the |
| 128 | + /// per-class [`ObjectView`](crate::ontology::ObjectView)'s `fields`. |
| 129 | + fn fields(&self, class: ClassId) -> &[FieldRef]; |
| 130 | + |
| 131 | + /// Which askama template renders this class. |
| 132 | + fn template(&self, class: ClassId) -> DisplayTemplate; |
| 133 | + |
| 134 | + /// The DOLCE upper-category of this class, RESOLVED from the ontology cache |
| 135 | + /// (not a stored enum on the row — OD-DOLCE "use the ontology cache"). Returned |
| 136 | + /// as the cache's opaque category id; the consumer maps it to its own enum. |
| 137 | + fn dolce_category_id(&self, class: ClassId) -> u8; |
| 138 | + |
| 139 | + /// The label of field position `n` in `class`, resolved late from the cache |
| 140 | + /// (locale resolution is the consumer's job). `None` if `n` is out of range. |
| 141 | + fn field_label(&self, class: ClassId, n: u8) -> Option<&str> { |
| 142 | + self.fields(class).get(n as usize).map(|f| f.label.as_str()) |
| 143 | + } |
| 144 | + |
| 145 | + /// The class's field count (mask width). Must be `<= FieldMask::MAX_FIELDS`. |
| 146 | + #[inline] |
| 147 | + fn field_count(&self, class: ClassId) -> usize { |
| 148 | + self.fields(class).len() |
| 149 | + } |
| 150 | + |
| 151 | + /// Project an instance: iterate `(field, populated?)` pairs in class order, |
| 152 | + /// gating each field by the presence `mask`. This is the render surface — the |
| 153 | + /// consumer skips off-bits (`cognitive-risc-classes.md`:49). The SoA supplied |
| 154 | + /// only `(class, mask)`; the labels come from the cache, above the SoA. |
| 155 | + fn project<'a>(&'a self, class: ClassId, mask: FieldMask) -> ClassProjection<'a> { |
| 156 | + ClassProjection { |
| 157 | + fields: self.fields(class), |
| 158 | + mask, |
| 159 | + pos: 0, |
| 160 | + } |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +/// An iterator over a class's fields paired with their presence bit — the |
| 165 | +/// projected view a render template consumes (off-bits are still yielded with |
| 166 | +/// `present = false` so the template can `{% if present %}`-skip them). |
| 167 | +pub struct ClassProjection<'a> { |
| 168 | + fields: &'a [FieldRef], |
| 169 | + mask: FieldMask, |
| 170 | + pos: usize, |
| 171 | +} |
| 172 | + |
| 173 | +impl<'a> Iterator for ClassProjection<'a> { |
| 174 | + /// `(field, present)` — `present` is the C2 presence bit, never a semantics bit. |
| 175 | + type Item = (&'a FieldRef, bool); |
| 176 | + |
| 177 | + fn next(&mut self) -> Option<Self::Item> { |
| 178 | + let f = self.fields.get(self.pos)?; |
| 179 | + let present = self.mask.has(self.pos as u8); |
| 180 | + self.pos += 1; |
| 181 | + Some((f, present)) |
| 182 | + } |
| 183 | +} |
| 184 | + |
| 185 | +#[cfg(test)] |
| 186 | +mod tests { |
| 187 | + use super::*; |
| 188 | + use crate::ontology::{DisplayTemplate, FieldRef}; |
| 189 | + |
| 190 | + /// A tiny in-contract ClassView fake — proves the trait is satisfiable and the |
| 191 | + /// meta-DTO projects above an agnostic (class, mask) input, no labels stored. |
| 192 | + struct FakeClasses { |
| 193 | + // class 7 = a 3-field shape ("invoice": amount, tax, partner) |
| 194 | + invoice: Vec<FieldRef>, |
| 195 | + } |
| 196 | + |
| 197 | + impl FakeClasses { |
| 198 | + fn new() -> Self { |
| 199 | + Self { |
| 200 | + invoice: vec![ |
| 201 | + FieldRef::new("amount_total", "Total"), |
| 202 | + FieldRef::new("amount_tax", "Tax"), |
| 203 | + FieldRef::new("partner_id", "Partner"), |
| 204 | + ], |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + impl ClassView for FakeClasses { |
| 210 | + fn fields(&self, class: ClassId) -> &[FieldRef] { |
| 211 | + match class { |
| 212 | + 7 => &self.invoice, |
| 213 | + _ => &[], |
| 214 | + } |
| 215 | + } |
| 216 | + fn template(&self, _class: ClassId) -> DisplayTemplate { |
| 217 | + DisplayTemplate::Detail |
| 218 | + } |
| 219 | + fn dolce_category_id(&self, _class: ClassId) -> u8 { |
| 220 | + 0 // Endurant, resolved from the cache in the real impl |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + #[test] |
| 225 | + fn field_mask_is_presence_bits() { |
| 226 | + let m = FieldMask::from_positions(&[0, 2]); // amount + partner populated, tax absent |
| 227 | + assert!(m.has(0) && !m.has(1) && m.has(2)); |
| 228 | + assert_eq!(m.count(), 2); |
| 229 | + assert!(!m.is_empty() && FieldMask::EMPTY.is_empty()); |
| 230 | + assert_eq!( |
| 231 | + FieldMask::EMPTY.with(1).with(1), |
| 232 | + FieldMask::from_positions(&[1]) |
| 233 | + ); |
| 234 | + } |
| 235 | + |
| 236 | + #[test] |
| 237 | + fn meta_dto_projects_above_agnostic_class_mask() { |
| 238 | + let classes = FakeClasses::new(); |
| 239 | + // The SoA supplied ONLY (class_id=7, mask) — no labels. The meta-DTO |
| 240 | + // resolves the labels from above. |
| 241 | + let mask = FieldMask::from_positions(&[0, 2]); // tax (pos 1) is off |
| 242 | + let projected: Vec<(&str, bool)> = classes |
| 243 | + .project(7, mask) |
| 244 | + .map(|(f, present)| (f.label.as_str(), present)) |
| 245 | + .collect(); |
| 246 | + assert_eq!( |
| 247 | + projected, |
| 248 | + vec![("Total", true), ("Tax", false), ("Partner", true)], |
| 249 | + "labels come from the cache above the SoA; presence comes from the mask" |
| 250 | + ); |
| 251 | + // The render template skips off-bits: only present fields surface. |
| 252 | + let rendered: Vec<&str> = classes |
| 253 | + .project(7, mask) |
| 254 | + .filter(|(_, present)| *present) |
| 255 | + .map(|(f, _)| f.label.as_str()) |
| 256 | + .collect(); |
| 257 | + assert_eq!(rendered, vec!["Total", "Partner"], "off-bit (Tax) skipped"); |
| 258 | + } |
| 259 | + |
| 260 | + #[test] |
| 261 | + fn field_label_resolves_late_from_class_not_row() { |
| 262 | + let classes = FakeClasses::new(); |
| 263 | + assert_eq!(classes.field_label(7, 1), Some("Tax")); |
| 264 | + assert_eq!(classes.field_label(7, 9), None); // out of range |
| 265 | + assert_eq!(classes.field_count(7), 3); |
| 266 | + assert_eq!(classes.field_count(999), 0); // unknown class |
| 267 | + } |
| 268 | +} |
0 commit comments