diff --git a/Cargo.toml b/Cargo.toml index 88a1e29..32e9f71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/ogar-from-elixir", "crates/ogar-from-ruff", "crates/ogar-from-rails", + "crates/ogar-class-view", ] [workspace.package] diff --git a/crates/ogar-class-view/Cargo.toml b/crates/ogar-class-view/Cargo.toml new file mode 100644 index 0000000..71cd07c --- /dev/null +++ b/crates/ogar-class-view/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ogar-class-view" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Bridge crate: lifts `ogar_vocab::Class` (canonical AR shape, codebook-keyed) onto `lance_graph_contract::ClassView` (presence-bitmask + render-row resolver). The seam between the calcified inner shape and the classview-parameterized outer materialization (askama/jinja). One trait impl over the 32 promoted canonical concepts; no I/O." + +[dependencies] +ogar-vocab = { path = "../ogar-vocab" } +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs new file mode 100644 index 0000000..60ced06 --- /dev/null +++ b/crates/ogar-class-view/src/lib.rs @@ -0,0 +1,384 @@ +//! `ogar-class-view` — the bridge from +//! [`ogar_vocab::Class`] (the calcified canonical AR shape, codebook-keyed) +//! onto [`lance_graph_contract::ClassView`] (the presence-bitmask + render-row +//! resolver). +//! +//! # What this crate is +//! +//! The seam between the *inner* design (canonical concept + typed attributes + +//! family edges, minted in [`ogar_vocab::CODEBOOK`]) and the *outer* design +//! (askama/jinja templates that materialize the same shape into Rust / TS / +//! SurrealQL / OpenAPI / …). The "ClassView" of +//! `lance-graph-contract::class_view` already supplies the *contract*: +//! +//! ```text +//! SoA row = the XML document (agnostic bytes) +//! class = the XSD schema (ordered field set) +//! ClassView = parser+schema (projects row → typed view) +//! FieldMask = optional-elements presence +//! askama template = the XSLT (renders the projection) +//! ``` +//! +//! This crate is the **adapter**: it builds an +//! [`ObjectView`](lance_graph_contract::ObjectView) per promoted canonical +//! concept by walking `ogar-vocab`'s class fns, keys them by +//! [`canonical_concept_id`](ogar_vocab::canonical_concept_id) (= the +//! `lance_graph_contract::ClassId`), and exposes the whole set through one +//! [`ClassView`](lance_graph_contract::ClassView) impl ([`OgarClassView`]). +//! +//! Once a renderer holds an `OgarClassView`, `class_view.render_rows(id, mask)` +//! returns typed rows the askama template iterates — no codegen output is +//! emitted *here*; that is the deferred render crate's job. +//! +//! # N3 stable field order (the bit basis) +//! +//! `FieldMask` bit `n` corresponds to the `n`-th `FieldRef` in the +//! [`ObjectView::fields`](lance_graph_contract::ObjectView::fields) slice. +//! Stability is contractual: once instances persist, bit positions are +//! append-only ([`lance_graph_contract::FieldMask`] doc). +//! +//! This crate's chosen order: +//! +//! 1. **Attributes first**, in declaration order from the class fn +//! (`name`, `position`, `permissions`, …). +//! 2. **Family edges (`Association`s) after**, in declaration order +//! (e.g. `belongs_to :project`, `has_many :work_items`, …). +//! +//! Tests in this crate pin that order for every promoted concept. A regen +//! that drops or reorders a slot trips them. +//! +//! # Why this crate has no `serde`, no I/O +//! +//! It is a *pure* in-process adapter. The registry is constructed at startup +//! by calling the 32 promoted class fns; nothing reads files or parses JSON. +//! Renderers that *do* need persistence (templating output, etc.) sit +//! downstream. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +use std::collections::HashMap; + +use lance_graph_contract::{ + class_view::{ClassId, ClassView}, + ontology::{DisplayTemplate, FieldRef, ObjectView}, +}; +use ogar_vocab::{ + billable_work_entry, billing_party, canonical_concept_id, commercial_document, + commercial_line_item, currency_policy, payment_record, priority, project, project_actor, + project_attachment, project_changeset, project_comment, project_custom_field, + project_custom_value, project_enabled_module, project_forum, project_journal, + project_member_role, project_membership, project_message, project_news, project_query, + project_relation, project_repository, project_role, project_status, project_type, + project_version, project_watcher, project_wiki_page, project_work_item, tax_policy, Class, +}; + +/// All 32 promoted canonical concepts: `(canonical_concept_name, Class)`. +/// +/// Walked at startup by [`OgarClassView::new`]. The list is exhaustive against +/// [`ogar_vocab::CODEBOOK`] — a test in this crate fails if a codebook entry +/// is missing from here (or vice versa). Adding a new canonical concept means +/// appending one line in OGAR (the class fn + codebook id) and one line here. +fn all_canonical_classes() -> Vec<(&'static str, Class)> { + vec![ + // ── 0x01XX — project-mgmt ── + ("project", project()), + ("project_work_item", project_work_item()), + ("billable_work_entry", billable_work_entry()), + ("project_actor", project_actor()), + ("project_status", project_status()), + ("project_type", project_type()), + ("priority", priority()), + ("project_membership", project_membership()), + ("project_journal", project_journal()), + ("project_repository", project_repository()), + ("project_version", project_version()), + ("project_wiki_page", project_wiki_page()), + ("project_query", project_query()), + ("project_attachment", project_attachment()), + ("project_comment", project_comment()), + ("project_custom_field", project_custom_field()), + ("project_relation", project_relation()), + ("project_changeset", project_changeset()), + ("project_watcher", project_watcher()), + ("project_news", project_news()), + ("project_message", project_message()), + ("project_forum", project_forum()), + ("project_role", project_role()), + ("project_member_role", project_member_role()), + ("project_custom_value", project_custom_value()), + ("project_enabled_module", project_enabled_module()), + // ── 0x02XX — commerce ── + ("commercial_line_item", commercial_line_item()), + ("commercial_document", commercial_document()), + ("tax_policy", tax_policy()), + ("billing_party", billing_party()), + ("payment_record", payment_record()), + ("currency_policy", currency_policy()), + ] +} + +/// Lift one canonical [`Class`] into its `ObjectView` (the field basis the +/// `FieldMask` bits index). +/// +/// Field order — **N3 stable, append-only**: +/// 1. Typed attributes, in declaration order on the class fn. +/// 2. Family-edge associations, in declaration order on the class fn. +/// +/// `predicate_iri` is the slot's name as written (snake_case); `label` +/// mirrors it (display localisation happens downstream). The +/// `DisplayTemplate` is left as the default +/// [`DisplayTemplate::Detail`] — per-class template selection is the +/// renderer's job, not this adapter's. +fn lift_object_view(class: &Class) -> ObjectView { + let mut fields: Vec = Vec::with_capacity(class.attributes.len() + class.associations.len()); + for attr in &class.attributes { + fields.push(FieldRef::new(attr.name.clone(), attr.name.clone())); + } + for assoc in &class.associations { + fields.push(FieldRef::new(assoc.name.clone(), assoc.name.clone())); + } + let mut view = ObjectView::new(DisplayTemplate::Detail, fields); + // Convention: primary_label is the first typed attribute named "name" + // when present, falling back to None (the contract treats None as + // "first field" — see `ObjectView` doc). + if class.attributes.iter().any(|a| a.name == "name") { + view.primary_label = Some("name".to_string()); + } + view +} + +/// [`ClassView`] implementation backed by [`ogar_vocab`]'s 32 promoted +/// canonical concepts. +/// +/// Construct once at startup with [`OgarClassView::new`]; the registry is +/// then read-only for the rest of the process. Per-class `ObjectView`s +/// (the field basis, ordered, N3 stable) and the empty-field-list fallback +/// outlive the view. +pub struct OgarClassView { + by_id: HashMap, + /// The fallback returned by [`ClassView::fields`] when a class id is + /// not in the registry — empty slice, so a `FieldMask` over an unknown + /// class projects to zero rows (consumer skips it). + empty_fields: Vec, +} + +impl OgarClassView { + /// Build the registry by walking every promoted class fn in + /// [`ogar_vocab`] and lifting it onto an [`ObjectView`]. Pure + /// construction; no I/O. + #[must_use] + pub fn new() -> Self { + let mut by_id = HashMap::new(); + for (concept, class) in all_canonical_classes() { + let id = canonical_concept_id(concept).unwrap_or_else(|| { + panic!("{concept} is in all_canonical_classes() but not in OGAR CODEBOOK") + }); + by_id.insert(id, lift_object_view(&class)); + } + Self { + by_id, + empty_fields: Vec::new(), + } + } + + /// The promoted class ids the registry currently exposes — useful for + /// downstream consumers that want to iterate every class (e.g. emit + /// one Rust struct per class). + pub fn known_class_ids(&self) -> impl Iterator + '_ { + self.by_id.keys().copied() + } + + /// Look up the [`ObjectView`] (the full per-class render spec — + /// fields + display template + primary label) for a class id, if + /// known. Lower-level than [`ClassView`]; useful when a renderer + /// needs the `primary_label` or `DisplayTemplate` directly. + pub fn object_view(&self, class: ClassId) -> Option<&ObjectView> { + self.by_id.get(&class) + } +} + +impl Default for OgarClassView { + fn default() -> Self { + Self::new() + } +} + +impl ClassView for OgarClassView { + fn fields(&self, class: ClassId) -> &[FieldRef] { + self.by_id + .get(&class) + .map(|v| v.fields.as_slice()) + .unwrap_or(self.empty_fields.as_slice()) + } + + fn template(&self, class: ClassId) -> DisplayTemplate { + self.by_id + .get(&class) + .map(|v| v.display_template.clone()) + .unwrap_or(DisplayTemplate::Detail) + } + + fn dolce_category_id(&self, _class: ClassId) -> u8 { + // DOLCE upper-category classification is the OGIT cache's job + // (`OD-DOLCE: use the ontology cache`). This adapter is below + // OGIT; the renderer that wires us with a DOLCE source supplies + // its own mapping. Default `0` = unclassified. + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lance_graph_contract::class_view::FieldMask; + + #[test] + fn registry_carries_every_codebook_concept() { + // Forward gate: every concept in `all_canonical_classes` resolves + // to a known codebook id and lands in the registry. + let v = OgarClassView::new(); + for (concept, _) in all_canonical_classes() { + let id = canonical_concept_id(concept) + .unwrap_or_else(|| panic!("{concept} missing from OGAR CODEBOOK")); + assert!( + v.object_view(id).is_some(), + "{concept} ({id:#06x}) absent from OgarClassView registry" + ); + } + // The registry knows exactly the 32 promoted concepts. + assert_eq!(v.known_class_ids().count(), all_canonical_classes().len()); + } + + #[test] + fn every_codebook_id_appears_in_class_ids_all() { + // Reverse gate: every (name, id) in `ogar_vocab::class_ids::ALL` + // must have a registry entry — guards against a CODEBOOK promotion + // landing in OGAR without being added here. + let v = OgarClassView::new(); + for (concept, id) in ogar_vocab::class_ids::ALL { + assert!( + v.object_view(*id).is_some(), + "{concept} ({id:#06x}) in class_ids::ALL but missing from OgarClassView registry" + ); + } + } + + #[test] + fn field_basis_fits_in_one_u64_mask() { + // FieldMask is a u64 — bit positions >= 64 are silently dropped + // (`lance_graph_contract::FieldMask::from_positions` doc). No + // promoted canonical class may have more than 64 slots. + let v = OgarClassView::new(); + for (concept, _) in all_canonical_classes() { + let id = canonical_concept_id(concept).unwrap(); + let n = v.field_count(id); + assert!( + n <= FieldMask::MAX_FIELDS as usize, + "{concept} has {n} fields, exceeds FieldMask::MAX_FIELDS ({})", + FieldMask::MAX_FIELDS + ); + } + } + + #[test] + fn field_order_is_attributes_then_associations() { + // The N3 bit-basis convention: typed attributes first (in + // declaration order), then family-edge associations (in + // declaration order). Pinned here so any reordering trips this + // test before existing FieldMask producers silently misalign. + let v = OgarClassView::new(); + let id = canonical_concept_id("billable_work_entry").unwrap(); + let class = billable_work_entry(); + let fields = v.fields(id); + + // First `class.attributes.len()` positions are attributes, in + // declaration order. + for (i, attr) in class.attributes.iter().enumerate() { + assert_eq!( + fields[i].predicate_iri, attr.name, + "billable_work_entry field[{i}] should be attribute {}", + attr.name + ); + } + // Then `class.associations.len()` positions are associations. + for (i, assoc) in class.associations.iter().enumerate() { + let pos = class.attributes.len() + i; + assert_eq!( + fields[pos].predicate_iri, assoc.name, + "billable_work_entry field[{pos}] should be association {}", + assoc.name + ); + } + assert_eq!( + fields.len(), + class.attributes.len() + class.associations.len() + ); + } + + #[test] + fn render_rows_skips_off_bits_under_a_real_mask() { + // The full path: class_id + FieldMask -> Vec. Pin the + // render_rows behaviour the askama template will iterate against. + let v = OgarClassView::new(); + let id = canonical_concept_id("project_work_item").unwrap(); + let n = v.field_count(id); + assert!(n > 0, "project_work_item must have at least one slot"); + + // Empty mask -> no rows. + let empty = v.render_rows(id, FieldMask::EMPTY); + assert!(empty.is_empty()); + + // Mask with only bit 0 set -> exactly one row, matching field[0]'s label. + let only_first = FieldMask::EMPTY.with(0); + let rows = v.render_rows(id, only_first); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, v.fields(id)[0].label.as_str()); + } + + #[test] + fn unknown_class_id_projects_to_no_rows() { + // A class id outside the registry must NOT panic — it projects + // to an empty field set, so the renderer skips the row safely. + let v = OgarClassView::new(); + assert!(v.fields(0xFFFF).is_empty()); + let rows = v.render_rows(0xFFFF, FieldMask::EMPTY.with(0).with(3)); + assert!(rows.is_empty()); + } + + #[test] + fn billable_work_entry_carries_all_12_family_edges() { + // The 12 family edges of billable_work_entry are part of its + // promoted contract (see `ogar_vocab::billable_work_entry` doc). + // The bridge must surface every one as a FieldRef. + let v = OgarClassView::new(); + let id = canonical_concept_id("billable_work_entry").unwrap(); + let class = billable_work_entry(); + let assoc_names: std::collections::HashSet<&str> = class + .associations + .iter() + .map(|a| a.name.as_str()) + .collect(); + let field_names: std::collections::HashSet<&str> = + v.fields(id).iter().map(|f| f.predicate_iri.as_str()).collect(); + for assoc in &assoc_names { + assert!( + field_names.contains(assoc), + "billable_work_entry family edge `{assoc}` missing from FieldRef set" + ); + } + assert!(class.associations.len() >= 12); + } + + #[test] + fn primary_label_is_set_for_concepts_with_a_name_attribute() { + // Convention: concepts that have a typed `name` attribute carry + // `primary_label = Some("name")` so a renderer can pull the + // headline without scanning the field list. + let v = OgarClassView::new(); + let id = canonical_concept_id("project").unwrap(); + let view = v.object_view(id).unwrap(); + // project() has a `name` attribute, so primary_label is set. + assert_eq!(view.primary_label.as_deref(), Some("name")); + } +} diff --git a/docs/integration/CLASSVIEW-MATERIALIZATION-PLAN.md b/docs/integration/CLASSVIEW-MATERIALIZATION-PLAN.md new file mode 100644 index 0000000..62bb202 --- /dev/null +++ b/docs/integration/CLASSVIEW-MATERIALIZATION-PLAN.md @@ -0,0 +1,125 @@ +# ClassView materialization plan — wiring the canonical → askama seam + +> Status: the bridge crate (`ogar-class-view`) lands in this PR. Templates + +> consumer wiring are the **+5+3** follow-ons enumerated below. + +## What just landed + +The seam from the **calcified inner shape** (`ogar_vocab::Class` + codebook id) +onto the **classview-parameterized outer materialization** surface (askama, in +`lance-graph-contract::class_view`'s framing). + +```text +ogar_vocab::Class ──┐ ┌──► Rust struct + (codebook id, attrs, │ │ + family edges) │ ogar-class-view │──► TS interface + │ (this PR) │ +ogar_vocab::class_ids::PROJECT_WORK_ITEM │──► askama ─► │──► SurrealQL TABLE + │ │ +lance_graph_contract:: │ │──► OpenAPI schema + ClassView trait ──┘ │ + └──► (target N+1) +``` + +The XSD analogy from `lance-graph-contract::class_view`: + +| Layer | Today | +|---|---| +| SoA row | the XML document — agnostic bytes | +| ObjectView (field set) | the XSD schema — bit basis | +| `OgarClassView` (this crate) | the parser+schema — projects row → typed view | +| `FieldMask` | optional-elements presence | +| askama template (deferred) | the XSLT — renders the projection | + +## The bridge contract + +`ogar-class-view` exposes one impl: `OgarClassView`. Construct once at +startup; the registry walks every promoted canonical concept (`project()`, +`project_work_item()`, `billable_work_entry()`, … the 32 entries in the +codebook) and lifts each into an `ObjectView`: + +- **`FieldRef` order = N3 stable, append-only**: attributes first (declaration + order), then family-edge associations (declaration order). A test pins it. +- **`ClassId` = `ogar_vocab::canonical_concept_id`** (same `u16`). +- **`render_rows(class_id, mask)`** is the askama input: list of populated + `(label, predicate_iri)` rows in field order, off-bits skipped. +- Unknown `ClassId` → empty field set (no panic). + +## +5 templates (the askama kit) + +One template per **artifact kind**, parameterized via classview-supplied +variables. The kit is the 7-70 number; the first 5 cover the targets +already in flight on the ecosystem. + +| PR | Template | Target | Consumers | +|---|---|---|---| +| **T1** | `rust_struct.askama` | Rust `struct` (newtype, Serialize/Deserialize, `pub const CLASS_ID: u16`) | `op-models`, `rm-models` (future) | +| **T2** | `ts_interface.askama` | TS `interface` + `class_ids.ts` | OpenProject frontend, future Redmine TS port | +| **T3** | `surrealql_table.askama` | SurrealQL `DEFINE TABLE` + field defs | `op-surreal-ast`, `ogar-adapter-surrealql` | +| **T4** | `openapi_schema.askama` | OpenAPI 3.1 `components.schemas.{X}` | API spec generation; SDK clients | +| **T5** | `node_guid_routing.askama` | Rust `match` arm dispatch keyed on `ClassId` for `NodeGuid`-shaped graph entries | `lance-graph-planner`, kanban router | + +Each PR is small (one template + a tiny render harness + a `cargo check` on +the emitted artifact). Shared invariant: every template uses the same +`(OgarClassView, ClassView_bits, ClassId, FieldMask)` context. **No template +hardcodes a class name.** + +## +3 calibration + consumer wiring + +| PR | What | Repo | +|---|---|---| +| **C1** | `op-codegen-projection` / `op-codegen-pipeline` adopt `OgarClassView` + askama for the templated paths currently using `format!`. Calibration test: emitted Rust still passes `cargo check`; emitted TS still passes `tsc --noEmit`. | `openproject-nexgen-rs` | +| **C2** | `redmine-canon` exposes `OgarClassView` re-exported from `ogar-class-view` (no logic), so `rm-*` domain crates (future) consume one canonical bridge. Symmetric move with `op-canon` already re-exporting `class_ids`. | `redmine-rs` | +| **C3** | `op-surreal-ast::from_class_view` — adapter from `OgarClassView` to the existing typed `Schema` AST (which is byte-identical-pinned). Lets the SurrealQL emission path consume the canonical shape *without* breaking the byte-identical-output pin: ClassView feeds the typed AST, the AST emits SurrealQL the same way it always has. | `openproject-nexgen-rs` | + +## Conformance gates (the calibration loop) + +Each of T1-T5 + C1-C3 lands with: + +1. **Round-trip test on the canonical layer**: emit → re-parse → assert + structural equality where the target supports it (Rust via `syn`; TS via + `swc`/`@babel/parser`; SurrealQL via `op-surreal-ast`). +2. **Compilation test**: emitted artifact compiles in its target toolchain + (`cargo check`, `tsc --noEmit`, SurrealQL parse). +3. **ClassView drift guard**: every `FieldRef` referenced in a template is + present in the registry for the rendered class (test renders against + `OgarClassView`, asserts no missing slots). + +The cascade — codebook calcified → `OgarClassView` typed → askama bound to +typed context → emitted artifact compiled — means a misstep anywhere fails +*before* runtime. That is the "Apple/iPhone" inner+outer integration: +declarative seams instead of negotiated ones. + +## Out of scope for this plan + +- ClassView **bitmask semantics per target** (which bits flip a slot + visible/hidden for Rust vs TS vs SurrealQL). Belongs in T1-T5 as each + template asserts what it needs; the contract emerges from the templates, + not pre-designed. +- DOLCE upper-category dispatch (`dolce_category_id`): the trait method is + implemented to return `0` (unclassified) today. Live DOLCE comes from + `lance-graph-ontology` when the consumer wires it. +- Persistence / serde on `ObjectView`. The registry is built in-process from + the canonical class fns; nothing reads files in this crate. + +## Risks + mitigations + +- **`FieldMask` is `u64` — 64 fields max.** Today's widest canonical class + (`billable_work_entry`, 12 family edges + a handful of attributes) is well + under. Test `field_basis_fits_in_one_u64_mask` fires if any future class + exceeds. Mitigation if it ever does: paginate via class hierarchy (the + contract's documented escape — see `lance-graph-contract` L0b). +- **`build.rs` of `lance-graph-contract` reads `modules/*/manifest.yaml` + from its checkout root.** As a git dep, that's the lance-graph checkout + cache — the manifests are checked out alongside the crate, so this works. + Sanity-checked locally on `cargo check`. +- **Template count creep.** The kit is bounded by *artifact kind*, not by + (class × target). New concepts cost zero new templates (they flow through + the existing kit via `OgarClassView`). New targets cost one template + + one classview bit position. + +## Pin + +The bridge is the **only** new artifact required to begin templating. +Everything else (T1-T5, C1-C3) is downstream and can land sprint-by-sprint +without re-touching this crate.