From 984f787977c9a636d0e0161de4a7e04b83c1fd3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 03:24:47 +0000 Subject: [PATCH 1/4] feat(ogar-from-ruff): project Odoo Model::fields into Class schema (Codex P1 #131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python lift previously only set the language discriminant; it read the Rails-side AR-DSL vectors (model.attributes / model.associations / …), which are empty for an Odoo model. An Odoo model carries its schema in the core-7 `Model::fields` vector, so the lifted class came out with empty attributes / associations / computed_fields — downstream consumers got a schema-less class (Codex P1 on #131). Add a Python-only `project_odoo_fields` pass mapping `Model::fields` onto the existing Class columns: - relational field (target set) -> Association; kind from `relation_kind` (many2one->BelongsTo, one2many->HasMany, many2many->HasAndBelongsToMany), class_name = raw comodel, inverse_of = One2many inverse. - non-relational field -> Attribute. - compute field (emitted_by set) -> ComputedField (method + @api.depends), in addition to its Attribute/Association. Gated on Language::Python: Rails ALSO populates Model::fields (DB columns), so projecting them for Rails would double-count its AR-DSL surface. Adds `ComputedField::new(field, compute_method)` to ogar-vocab (it lacked a constructor; #[non_exhaustive] makes a struct literal non-constructible cross-crate — parallels Association::new / Attribute::new). Consumes ruff's `relation_kind` predicate (AdaWorldAPI/ruff#35): target + inverse_name alone can't separate a Many2one from a Many2many (both comodel-only, no inverse), so the kind is required to pick the right AssociationKind. Tests: lift_model_python_projects_odoo_fields (scalar/relational/compute, incl. the M2O-vs-M2M case) + lift_model_ruby_does_not_project_fields (Ruby gating). Verified via probe (local ruff w/ relation_kind + real ogar-vocab): 30 tests pass, clippy -D warnings clean. NOTE: not yet pushed — gated on ruff#35 landing on ruff main + an OGAR Cargo.lock ruff-commit bump. Co-Authored-By: Claude --- crates/ogar-from-ruff/src/lib.rs | 164 ++++++++++++++++++++++++++++++- crates/ogar-vocab/src/lib.rs | 14 +++ 2 files changed, 175 insertions(+), 3 deletions(-) diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 60465f0..f02feed 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -66,7 +66,7 @@ use ogar_vocab::{ canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class, - EnumDecl, EnumSource, Inheritance, Language, Scope, Validation, + ComputedField, EnumDecl, EnumSource, Inheritance, Language, Scope, Validation, }; use ruff_spo_triplet::{ AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model, @@ -181,9 +181,69 @@ fn lift_model_with_language(model: &Model, language: Language) -> Class { class.callbacks = model.callbacks.iter().map(lift_callback).collect(); class.validations = model.validations.iter().filter_map(lift_validation).collect(); class.default_scope = lift_default_scope(model); + // Rails carries its schema in the AR-DSL vectors lifted above; an Odoo + // model instead declares everything as `fields.X(...)`, which lands in + // the core-7 `Model::fields` vector (empty for Rails). Project it so the + // Python lift doesn't drop the schema (Codex P1, PR #131). + if matches!(language, Language::Python) { + project_odoo_fields(&mut class, model); + } class } +/// Project an Odoo model's core-7 [`Model::fields`] onto the +/// schema-carrying [`Class`] columns. Python-only: Rails models keep their +/// schema in `model.attributes` / `model.associations` (lifted separately), +/// and ALSO populate `model.fields` (DB columns), so projecting fields for +/// Rails would double-count. Odoo leaves the AR vectors empty and puts +/// everything in `fields`. +/// +/// Per-field mapping: +/// - relational field (`target` set) → [`Association`]; the kind comes from +/// the field's cardinality (`relation_kind`), `class_name` is the raw +/// comodel, `inverse_of` the One2many inverse. +/// - non-relational field → [`Attribute`] (name only — the Odoo field type +/// is not yet carried on the SPO `Field`; a follow-up). +/// - compute field (`emitted_by` set) → [`ComputedField`] (method + +/// `@api.depends`), in addition to its Attribute / Association above. +fn project_odoo_fields(class: &mut Class, model: &Model) { + for field in &model.fields { + if let Some(comodel) = &field.target { + let kind = odoo_relation_kind(field.relation_kind.as_deref(), field.inverse_name.is_some()); + let mut assoc = Association::new(kind, &field.name); + assoc.class_name = Some(comodel.clone()); + assoc.inverse_of = field.inverse_name.clone(); + class.associations.push(assoc); + } else { + class.attributes.push(Attribute::new(&field.name)); + } + if let Some(compute_method) = &field.emitted_by { + let mut computed = ComputedField::new(&field.name, compute_method); + computed.depends = field.depends_on.clone(); + class.computed_fields.push(computed); + } + } +} + +/// Map an Odoo relation cardinality to the canonical [`AssociationKind`]. +/// +/// `relation_kind` (`many2one` / `one2many` / `many2many`, from ruff's +/// `relation_kind` predicate) is the authoritative signal. The +/// `has_inverse` fallback covers the theoretical case of a relational field +/// with no recorded cardinality: an inverse implies a One2many, otherwise +/// the to-one default `BelongsTo`. `target` + `inverse_name` alone cannot +/// separate a Many2one from a Many2many — both are comodel-only with no +/// inverse — which is exactly why `relation_kind` exists. +fn odoo_relation_kind(relation_kind: Option<&str>, has_inverse: bool) -> AssociationKind { + match relation_kind { + Some("many2one") => AssociationKind::BelongsTo, + Some("one2many") => AssociationKind::HasMany, + Some("many2many") => AssociationKind::HasAndBelongsToMany, + _ if has_inverse => AssociationKind::HasMany, + _ => AssociationKind::BelongsTo, + } +} + // ───────────────────────── ProjectWorkItem role projection ───────────── // // Curator-side mapping: project_work_item's canonical roles vs the @@ -604,8 +664,8 @@ fn parse_bool(s: &str) -> Option { mod tests { use super::*; use ruff_spo_triplet::{ - ActsAs, AssocDecl, AttrDecl, Callback as RuffCallback, ConcernRef, Function, ScopeDecl, - Validation as RuffValidation, + ActsAs, AssocDecl, AttrDecl, Callback as RuffCallback, ConcernRef, Field, Function, + ScopeDecl, Validation as RuffValidation, }; fn mk_model() -> Model { @@ -716,6 +776,104 @@ mod tests { assert_eq!(classes[0].source_curator.as_deref(), Some("odoo")); } + /// An Odoo-shape model: schema lives entirely in `Model::fields` + /// (the AR-DSL vectors are empty, as `ruff_python_spo` produces). + fn mk_odoo_model() -> Model { + let mut m = Model::new("account_move"); + m.fields.push(Field { + name: "name".to_string(), + ..Default::default() + }); + m.fields.push(Field { + name: "partner_id".to_string(), + target: Some("res.partner".to_string()), + relation_kind: Some("many2one".to_string()), + ..Default::default() + }); + m.fields.push(Field { + name: "line_ids".to_string(), + target: Some("account.move.line".to_string()), + inverse_name: Some("move_id".to_string()), + relation_kind: Some("one2many".to_string()), + ..Default::default() + }); + m.fields.push(Field { + name: "tag_ids".to_string(), + target: Some("account.analytic.tag".to_string()), + relation_kind: Some("many2many".to_string()), + ..Default::default() + }); + m.fields.push(Field { + name: "amount_total".to_string(), + emitted_by: Some("_compute_amount".to_string()), + depends_on: vec!["line_ids.balance".to_string()], + ..Default::default() + }); + m + } + + #[test] + fn lift_model_python_projects_odoo_fields() { + // Codex P1 (#131): the Python lift must project `Model::fields` or the + // class loses its whole schema. Scalar → attribute, relational → + // association (kind from relation_kind), compute → computed_field. + let class = lift_model_python(&mk_odoo_model()); + + // Scalar + computed fields surface as attributes (named columns). + let attr_names: Vec<&str> = class.attributes.iter().map(|a| a.name.as_str()).collect(); + assert!(attr_names.contains(&"name")); + assert!(attr_names.contains(&"amount_total")); + // Relational fields are associations, not attributes. + assert!(!attr_names.contains(&"partner_id")); + assert!(!attr_names.contains(&"line_ids")); + + // relation_kind drives the AssociationKind; comodel → class_name. + let partner = class + .associations + .iter() + .find(|a| a.name == "partner_id") + .expect("partner_id association"); + assert_eq!(partner.kind, AssociationKind::BelongsTo); + assert_eq!(partner.class_name.as_deref(), Some("res.partner")); + + let lines = class + .associations + .iter() + .find(|a| a.name == "line_ids") + .expect("line_ids association"); + assert_eq!(lines.kind, AssociationKind::HasMany); + assert_eq!(lines.class_name.as_deref(), Some("account.move.line")); + assert_eq!(lines.inverse_of.as_deref(), Some("move_id")); + + // The case relation_kind exists to disambiguate: a comodel-only, + // inverse-less field is a Many2many, NOT a Many2one. + let tags = class + .associations + .iter() + .find(|a| a.name == "tag_ids") + .expect("tag_ids association"); + assert_eq!(tags.kind, AssociationKind::HasAndBelongsToMany); + assert_eq!(tags.class_name.as_deref(), Some("account.analytic.tag")); + + // Compute field → computed_field carrying method + @api.depends. + assert_eq!(class.computed_fields.len(), 1); + let computed = &class.computed_fields[0]; + assert_eq!(computed.field, "amount_total"); + assert_eq!(computed.compute_method, "_compute_amount"); + assert_eq!(computed.depends, vec!["line_ids.balance".to_string()]); + } + + #[test] + fn lift_model_ruby_does_not_project_fields() { + // The Rails lift reads the AR-DSL vectors, never `Model::fields` + // (Rails also populates `fields`, so projecting them would + // double-count). An Odoo-shape model lifted as Ruby stays empty. + let class = lift_model(&mk_odoo_model()); + assert!(class.attributes.is_empty()); + assert!(class.associations.is_empty()); + assert!(class.computed_fields.is_empty()); + } + #[test] fn lift_inheritance_concrete_from_sti_parent() { // mk_model's StiInfo has inherits_from = Some("Issue"). diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 3ddb02a..110067e 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1978,6 +1978,20 @@ impl Attribute { } } +impl ComputedField { + /// Build a new computed-field declaration with the field name and its + /// compute method set. Remaining metadata (`depends`, `stored`, + /// `inverse_method`, …) is filled in by the caller. + #[must_use] + pub fn new(field: impl Into, compute_method: impl Into) -> Self { + Self { + field: field.into(), + compute_method: compute_method.into(), + ..Default::default() + } + } +} + impl Scope { /// Build a new scope with name and body source. #[must_use] From 0ef392520d16d10c01093c974a6d8ba688fa15f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 14:09:30 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(ogar-from-ruff):=20per-class=20minting?= =?UTF-8?q?=20=E2=80=94=20OGAR=20transpile=20substrate=20addressing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the operator's framing: OGAR is the transpile substrate that holds the ~85% mechanical business logic as per-class minted, rail-shaped, language-agnostic compiled classes; consumers (odoo-rs, medcare-rs, …) collapse to thin compiler-store callers + a few adapters for the "impossible" 15% (intrusive/stateful logic → custom adapter + ClassView + ontological grounding). Each language adds only a thin wrapper contract (lance-graph-contract is Rust's). This adds the substrate's addressing step — the other half of the lift: - `mint_graph(&ModelGraph) -> Mint` — mints the 16-byte rail facet for every node, resolving each render classid through a language/app port (OdooPort / OpenProjectPort / …) via the canonical `ogar_vocab::app::render_classid_for`. Frontend-agnostic: every ogar-from- yields the same ModelGraph. - `CompiledClass { class, facet }` + `compile_graph_python

` — pairs the lifted schema with its rail address: the unit a consumer pulls back. - classid = (APP_PREFIX << 16) | concept. The low u16 is the shared concept (PortSpec::class_id → OGAR codebook); the high u16 is the app render skin. account.move → 0x0002_0202 (Odoo | commercial_document); OpenProject WorkPackage → 0x0001_0102 (OpenProject | project_work_item) — different skins, the same identity where they converge. Unmapped model → classid 0 (bootstrap address), never mis-stamped. Consumes ruff's mint (ruff_spo_address, on ruff main). Members inherit their class's render classid. Tests: account.move compiles to a commercial_document rail class (schema + facet 0x0002_0202); member facets inherit the class id; port-agnostic mint (WorkPackage → 0x0001_0102); unmapped model → bootstrap 0. Verified via probe (local ruff + real ogar-vocab): 34 tests pass, clippy -D warnings clean. OGAR workspace can't resolve offline (surrealdb 403); CI builds it. Co-Authored-By: Claude --- crates/ogar-from-ruff/Cargo.toml | 1 + crates/ogar-from-ruff/src/lib.rs | 2 + crates/ogar-from-ruff/src/mint.rs | 226 ++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 crates/ogar-from-ruff/src/mint.rs diff --git a/crates/ogar-from-ruff/Cargo.toml b/crates/ogar-from-ruff/Cargo.toml index 9b08732..fd00f6c 100644 --- a/crates/ogar-from-ruff/Cargo.toml +++ b/crates/ogar-from-ruff/Cargo.toml @@ -15,4 +15,5 @@ serde = ["dep:serde", "ogar-vocab/serde"] [dependencies] ogar-vocab = { path = "../ogar-vocab" } ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } +ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } serde = { workspace = true, optional = true } diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index f02feed..ca90a54 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -64,6 +64,8 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod mint; + use ogar_vocab::{ canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class, ComputedField, EnumDecl, EnumSource, Inheritance, Language, Scope, Validation, diff --git a/crates/ogar-from-ruff/src/mint.rs b/crates/ogar-from-ruff/src/mint.rs new file mode 100644 index 0000000..d007600 --- /dev/null +++ b/crates/ogar-from-ruff/src/mint.rs @@ -0,0 +1,226 @@ +//! Per-class minting — the OGAR transpile substrate's addressing step. +//! +//! [`lift_model_graph_python`](crate::lift_model_graph_python) turns a +//! [`ModelGraph`] into the schema-carrying [`Class`]es. This module adds the +//! other half: the 16-byte **rail facet** for each class, with its render +//! classid resolved through a language/app [`PortSpec`] +//! (`OdooPort` / `OpenProjectPort` / …). The pair is the unit a consumer +//! pulls back — language-agnostic compiled business logic, addressed by +//! classid: +//! +//! ```text +//! ModelGraph (any ogar-from- frontend) +//! │ lift_model_graph_* -> Vec (the schema) +//! │ expand + mint_with_classid -> Mint (the rail facets) +//! ▼ +//! Vec +//! ▲ +//! a consumer (odoo-rs, medcare-rs, …) pulls these via its thin wrapper +//! contract (lance-graph-contract is Rust's) — the "impossible" 15 % +//! (intrusive / stateful logic) stays a per-language adapter + ClassView. +//! ``` +//! +//! The classid is the cross-app join key: the **low u16** is the shared +//! concept ([`PortSpec::class_id`] → the OGAR codebook), the **high u16** is +//! the app render prefix ([`PortSpec::APP_PREFIX`]). So `account.move` lands +//! as `0x0002_0202` under [`OdooPort`](ogar_vocab::ports::OdooPort) and an +//! OpenProject `WorkPackage` lands as `0x0001_0102` under +//! [`OpenProjectPort`](ogar_vocab::ports::OpenProjectPort) — different render +//! skins, the same conceptual identity where they converge. + +use ogar_vocab::app::render_classid_for; +use ogar_vocab::ports::PortSpec; +use ogar_vocab::Class; +use ruff_spo_address::{mint_with_classid, Facet, Mint}; +use ruff_spo_triplet::{expand, ModelGraph}; + +use crate::lift_model_graph_python; + +/// A class compiled to its rail-shaped, language-agnostic form: the lifted +/// schema ([`Class`]) plus its 16-byte address ([`Facet`]). This is what a +/// consumer pulls from the OGAR substrate and renders through its own +/// `ClassView` — the schema says *what the class is*, the facet says *where +/// it lives* (classid + the `part_of` / `is_a` cascade). +#[derive(Debug, Clone)] +pub struct CompiledClass { + /// The lifted schema — attributes / associations / computed fields. + pub class: Class, + /// The 16-byte rail facet: render classid + the `part_of` / `is_a` + /// tier chains. + pub facet: Facet, +} + +/// Mint the rail facet for every node in `graph`, resolving each node's +/// render classid through port `P`. Frontend-agnostic: any +/// `ogar-from-` produces the same [`ModelGraph`], so the mint is +/// shared. The returned [`Mint`] addresses every node (model *and* member) +/// — look one up with [`Mint::facet`]. +#[must_use] +pub fn mint_graph(graph: &ModelGraph) -> Mint { + let triples = expand(graph); + mint_with_classid(&triples, |node| classid_for_node::

(node)) +} + +/// Compile a Python/Odoo [`ModelGraph`] into rail-shaped [`CompiledClass`]es: +/// lift each model's schema and pair it with its minted facet, classid via +/// port `P`. Declaration order is preserved (mirrors +/// [`lift_model_graph_python`]). +#[must_use] +pub fn compile_graph_python(graph: &ModelGraph) -> Vec { + let mint = mint_graph::

(graph); + lift_model_graph_python(graph) + .into_iter() + .map(|class| { + let node = format!("{}:{}", graph.namespace, class.name); + // A model always appears as a subject node (`rdf:type ObjectType`), + // so it mints; the fallback covers a degenerate graph by stamping + // the classid with empty tier chains (still a valid rail address). + let facet = mint + .facet(&node) + .unwrap_or_else(|| Facet::from_parts(classid_for_node::

(&node), [0; 6], [0; 6])); + CompiledClass { class, facet } + }) + .collect() +} + +/// Resolve a node IRI's full render classid via port `P`. +/// +/// The IRI is `:` or `:.`; members inherit +/// their class's id, so we resolve the *model*'s concept and compose the +/// render classid `(APP_PREFIX << 16) | concept`. Odoo models arrive +/// underscore-normalised in the IRI (`account_move`) while ports key on the +/// dotted name (`account.move`), so we try the dotted form first, then the +/// raw form (mirrors `od-ontology`'s `concept_classid`). An unmapped model +/// resolves to `0` — the bootstrap address, left for the registry / +/// ref-escape path, never silently mis-stamped. +fn classid_for_node(node: &str) -> u32 { + let model = model_of(node); + P::class_id(&model.replace('_', ".")) + .or_else(|| P::class_id(model)) + .map_or(0, render_classid_for::

) +} + +/// The model segment of a node IRI: strip the `:` prefix, take up to the +/// first `.` (the model↔member separator). +fn model_of(node: &str) -> &str { + let body = node.split_once(':').map_or(node, |(_, rest)| rest); + body.split_once('.').map_or(body, |(model, _)| model) +} + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::ports::{OdooPort, OpenProjectPort}; + use ruff_spo_triplet::{Field, Function, Model}; + + // A representative `account.move` `ModelGraph`, constructed directly (the + // source→ModelGraph parse is `ruff_python_spo`'s job, tested there). Carries + // the relation-aware shape this session's ruff#34/#35 work added. + fn account_move_graph() -> ModelGraph { + let mut m = Model::new("account_move"); + m.fields.push(Field { + name: "name".to_string(), + ..Default::default() + }); + m.fields.push(Field { + name: "partner_id".to_string(), + target: Some("res.partner".to_string()), + relation_kind: Some("many2one".to_string()), + ..Default::default() + }); + m.fields.push(Field { + name: "line_ids".to_string(), + target: Some("account.move.line".to_string()), + inverse_name: Some("move_id".to_string()), + relation_kind: Some("one2many".to_string()), + ..Default::default() + }); + m.fields.push(Field { + name: "amount_total".to_string(), + emitted_by: Some("_compute_amount".to_string()), + depends_on: vec!["line_ids.balance".to_string()], + ..Default::default() + }); + m.functions.push(Function { + name: "_compute_amount".to_string(), + reads: Vec::new(), + raises: Vec::new(), + traverses: Vec::new(), + }); + let mut g = ModelGraph::new("odoo"); + g.models.push(m); + g + } + + #[test] + fn account_move_compiles_to_commercial_document_rail_class() { + let graph = account_move_graph(); + let compiled = compile_graph_python::(&graph); + assert_eq!(compiled.len(), 1); + let cc = &compiled[0]; + + // The schema is present (the #132 field projection): partner_id / + // line_ids land as associations, amount_total as a computed field. + assert_eq!(cc.class.name, "account_move"); + assert!( + !cc.class.associations.is_empty(), + "relational fields project into associations" + ); + assert!( + !cc.class.computed_fields.is_empty(), + "amount_total projects into computed_fields" + ); + + // The rail facet carries the canonical render classid — Odoo prefix + // 0x0002 | commercial_document concept 0x0202. + assert_eq!(cc.facet.facet_classid(), 0x0002_0202); + assert_eq!(cc.facet.facet_classid() & 0xFFFF, 0x0202, "shared concept"); + assert_eq!( + (cc.facet.facet_classid() >> 16) as u16, + OdooPort::APP_PREFIX, + "Odoo render prefix", + ); + } + + #[test] + fn member_facets_inherit_the_class_render_classid() { + let graph = account_move_graph(); + let mint = mint_graph::(&graph); + // Members are addressed within their class: same render classid. + for node in ["odoo:account_move", "odoo:account_move.partner_id"] { + let f = mint.facet(node).unwrap_or_else(|| panic!("{node} mints")); + assert_eq!(f.facet_classid(), 0x0002_0202, "{node} classid"); + } + } + + #[test] + fn mint_is_port_agnostic_same_concept_different_render_prefix() { + // The mint doesn't care about the source language — only the port + // lens. An OpenProject WorkPackage graph minted through OpenProjectPort + // lands as 0x0001_0102 (OpenProject prefix | project_work_item), the + // SAME low-u16 concept Odoo/Redmine converge on, a different prefix. + let mut graph = ModelGraph::new("openproject"); + graph.models.push(Model::new("WorkPackage")); + let mint = mint_graph::(&graph); + let facet = mint + .facet("openproject:WorkPackage") + .expect("WorkPackage mints"); + assert_eq!(facet.facet_classid(), 0x0001_0102); + assert_eq!(facet.facet_classid() & 0xFFFF, 0x0102, "project_work_item"); + assert_eq!( + (facet.facet_classid() >> 16) as u16, + OpenProjectPort::APP_PREFIX, + ); + } + + #[test] + fn unmapped_model_mints_the_bootstrap_address_not_a_wrong_classid() { + // A model with no codebook entry resolves to classid 0 (bootstrap), + // never a mis-stamped foreign id. + let mut graph = ModelGraph::new("odoo"); + graph.models.push(Model::new("ir_cron")); + let mint = mint_graph::(&graph); + let facet = mint.facet("odoo:ir_cron").expect("node mints"); + assert_eq!(facet.facet_classid(), 0, "unmapped -> bootstrap address"); + } +} From 57c2691b5eb408bac07227c3f3e0915b33bc95b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 14:31:02 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(ogar):=20OGAR=20as=20the=20per-class?= =?UTF-8?q?=20transpile=20substrate=20=E2=80=94=20the=20power,=20in=20one?= =?UTF-8?q?=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For any future session to understand the power. Names the whole machine: - The two legs of the bidirectional transpiler: PULL-IN (source → ogar-from- → ModelGraph → lift + mint → CompiledClass) and PULL-BACK (a consumer obtains the CompiledClass via a thin runtime wrapper contract — lance-graph-contract is Rust's — or a codegen emit adapter — ogar-adapter-surrealql is the DDL reference). - The addressing that makes it a substrate, not a dump: classid = (APP_PREFIX<<16)|concept (cross-app join key, the low u16 is the shared concept, the high u16 the render skin), the 16-byte FacetCascade (V3 6×part_of:is_a), cross-app convergence (account.move ↔ WorkPackage ↔ TimeEntry/Stundenzettel/account.analytic.line). - The 85/15 split: ~85% mechanical logic minted into OGAR (consumer = a compile_graph caller); the "impossible" 15% = a per-language adapter + ClassView + ontological grounding (od-posting GoBD is the example). - Grounding = resolve, don't store (classid → ClassView → OGIT; FIBO/DOLCE never copied onto rows). - A worked account.move example (→ 0x0002_0202), the built/next state, and the four moves to extend (source lang / target lang / concept / port). Indexed in CLAUDE.md's doc family so a future session finds it at session start. Companion to OGAR-AS-IR.md (the compiler framing) and the #133 handover (the ERP/planning landing plan). Co-Authored-By: Claude --- CLAUDE.md | 11 ++ docs/OGAR-TRANSPILE-SUBSTRATE.md | 272 +++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 docs/OGAR-TRANSPILE-SUBSTRATE.md diff --git a/CLAUDE.md b/CLAUDE.md index 61b30bd..a31ea72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,17 @@ alignment costs. Until measured: 3×4 stands. slot to `KausalSpec`, a new lowering pass, or any other IR-surface change. The framing changes no existing decision; it changes every future one. +10. `docs/OGAR-TRANSPILE-SUBSTRATE.md` — **the power, in one doc.** OGAR + as the bidirectional per-class transpiler: pull-in (`source → + ogar-from- → ModelGraph → lift + mint → CompiledClass`), + rail-facet addressing (`classid = (APP_PREFIX<<16)|concept`, the 16-byte + `FacetCascade`, cross-app convergence), pull-back (runtime wrapper + contract like `lance-graph-contract`, or codegen emit like + `ogar-adapter-surrealql`), and the **85/15 split** (mechanical logic + minted into OGAR; the "impossible" 15% = a per-language adapter + + ClassView + ontological grounding). READ to understand why a consumer + collapses to "a compiler-store caller + adapters, at the cost of an + import." Worked example: `account.move → 0x0002_0202`. 8. `docs/ARCHITECTURAL-DECISIONS-2026-06-04.md` — ADR-001..025 (ADR-026 pending). 9. `.claude/agents/` — the 5+3 hardening pattern (5 research savants + diff --git a/docs/OGAR-TRANSPILE-SUBSTRATE.md b/docs/OGAR-TRANSPILE-SUBSTRATE.md new file mode 100644 index 0000000..e44cd95 --- /dev/null +++ b/docs/OGAR-TRANSPILE-SUBSTRATE.md @@ -0,0 +1,272 @@ +# OGAR as the Per-Class Transpile Substrate + +> **Read this to understand the power.** OGAR is not "a codebook" or "a DTO +> store" — it is a **bidirectional transpiler** whose unit of currency is the +> *per-class, rail-shaped, language-agnostic compiled class*. This doc names +> the whole machine: how a class is pulled IN from any source language, minted +> into a rail address, and pulled BACK into any consumer language through a +> thin wrapper contract. Companion to `OGAR-AS-IR.md` (the compiler framing) +> and the `#133` handover (the ERP/planning landing plan). + +--- + +## 0. The power in one paragraph + +OGAR compiles business logic from any source language (Python/Odoo, +Ruby/Rails, C#, …) into **per-class compiled classes**, each addressed by a +16-byte **rail facet** whose `classid` is a cross-app join key. ~**85 %** of a +consumer's logic — the mechanical, data-shaped part (fields, relations, +computed values, validations, the schema) — lives in OGAR as these minted +classes. A consumer in **any** language pulls a class back through a thin +**wrapper contract** (`lance-graph-contract` is Rust's) and reimplements +**nothing**. The **"impossible" 15 %** — intrusive, stateful, or +genuinely-language-specific logic — is a small per-language **adapter + +ClassView + ontological grounding**. One canonical class, N languages, +cross-app convergence, **at the cost of an import.** "ERP, OpenProject, … +for everything" falls out of the codebook, not out of per-app reimplementation. + +--- + +## 1. The two legs (the transpiler is bidirectional) + +``` + ┌─────────────────────── OGAR substrate ───────────────────────┐ + PULL-IN │ │ PULL-BACK + (source → OGAR) │ ModelGraph ──lift──► ogar_vocab::Class (the schema) │ (OGAR → language) + │ │ ──mint──► Facet (16B rail addr) (the address) │ + Python/Odoo ─┐ │ │ │ ┌─► Rust : import lance-graph-contract + ruff_python_spo │ └───────────────► CompiledClass { class, facet } ──────┼───┤ (ClassView + FacetCascade, runtime) + Ruby/Rails ─┤ ruff_* │ ▲ │ ├─► C# : a thin C# wrapper contract + ruff_ruby_spo ──IR──► │ │ pulled by classid │ ├─► Python: a thin Python wrapper contract + C#/… ─┘ (shared) │ │ │ └─► any DDL/codegen: ogar-adapter-* + └────────────────────────────────────────────────────────────────┘ +``` + +**Pull-in** — `source → ogar-from- → ModelGraph → lift + mint → CompiledClass`: + +| step | crate / fn | output | +|---|---|---| +| parse | `ruff_python_spo` / `ruff_ruby_spo` (ruff frontends) | `ruff_spo_triplet::ModelGraph` (the shared, language-neutral IR) | +| schema | `ogar-from-ruff::lift_model_graph_python` (+ `..._ruby`) | `Vec` — attributes / associations / computed_fields | +| address | `ogar-from-ruff::mint::mint_graph

` | `ruff_spo_address::Mint` — a 16-byte `Facet` per node | +| compile | `ogar-from-ruff::mint::compile_graph_python

` | `Vec` — **the unit a consumer pulls** | + +**Pull-back** — a consumer obtains a `CompiledClass` by either: + +- **(a) runtime wrapper contract** — the consumer imports a thin contract and + resolves the class by `classid` at runtime. `lance-graph-contract` is the + Rust contract: `ClassView` (the schema/render surface), `FacetCascade` (the + 16-byte address), `ActionDef`/`KausalSpec` (behaviour). No codegen — the + class is *data* interpreted through the contract's traits. A C#/Python + consumer ships an analogous thin contract. +- **(b) codegen emit adapter** — OGAR emits the class as target-language + source/DDL. `ogar-adapter-surrealql` (`Class → SurrealQL DDL`) is the + reference emitter; per-language emitters (`ogar-emit-rust`, …) follow the + same `CompiledClass → String` seam. + +The two modes are not rivals: (a) is the live "pull a class and render it" +path; (b) is the "materialise the class as source for a build target" path. +Both consume the same `CompiledClass`. + +--- + +## 2. Why it is a *substrate*, not a dump — the addressing + +A minted class is **addressable without decoding its value**. That is the +whole power: a renderer/router/planner lays out, groups, and skeleton-renders +classes from the 16-byte key alone. + +### 2.1 The classid (the cross-app join key) + +``` +render classid (u32) = (APP_PREFIX as u32) << 16 | concept (u16) + └── high u16 ──┘ └── low u16 ──┘ + the app RENDER skin the SHARED concept +``` + +- **low u16 = the shared concept** — resolved by `PortSpec::class_id` against + the OGAR codebook (`ogar_vocab::class_ids` / `ogar_codebook`). This is the + de-facto `owl:equivalentClass` expressed as a u16. +- **high u16 = the app render skin** — `PortSpec::APP_PREFIX`. Picks the + per-app `ClassView` / template; **carries no behaviour**. +- composed by the canonical `ogar_vocab::app::render_classid_for::

(concept)`. + +**Cross-app convergence is the payoff.** The same concept across apps gets the +same low u16, so a consumer joins across apps with a `==`: + +| concept | id | Odoo (`0x0002`) | OpenProject (`0x0001`) | Redmine (`0x0007`) | WoA/SMB (`0x0003`/`0x0004`) | +|---|---|---|---|---|---| +| `commercial_document` | `0x0202` | `account.move`, `sale.order` | — | — | `Rechnung`/`Invoice`/`Vorgang` | +| `project_work_item` | `0x0102` | — | `WorkPackage` | `Issue` | — | +| `billable_work_entry` | `0x0103` | `account.analytic.line` | `TimeEntry` | `TimeEntry` | `Stundenzettel` | + +`billable_work_entry` is the **named first cross-domain bridge**: a logged +unit of work is one concept whether it arrives from the planning arm +(OpenProject) or the commerce arm (Odoo). Pinned in +`ogar_vocab::ports::tests::billable_work_entry_converges_across_all_five_ports`. + +### 2.2 The 16-byte rail facet (`FacetCascade` — the "V3" schema) + +``` +facet_classid : u32 rows 0 ← the classid above +tiers[0..6] : FacetTier rows 1-3 ← 6× (lo:hi) = (is_a : part_of) byte pairs + hi_chain() = part_of cascade (containment) + lo_chain() = is_a cascade (inheritance) +``` + +- 16 bytes, content-blind, SIMD-transpose-native. `ruff_spo_address::Facet` + and `lance_graph_contract::facet::FacetCascade` are **byte-identical** — + the mint's bytes round-trip losslessly into the Foundry's facet (proven by + the cross-crate round-trip probe). +- `prefix_distance()` = `8 − shared_prefix_tiles()` → O(1) hierarchy distance, + no value decode. +- The mint builds two forests from the SPO triples — `part_of` (inverted + `has_field`/`has_function`) and `is_a` (`inherits_from`, fallback + `rdf:type`) — and stamps each node's coarse→fine rank chains. + +### 2.3 Compression never costs addressability + +A node is `key(128/GUID) + value`. Lance may compress the value arbitrarily +(columnar, dictionary, PQ); the key is never compressed and never needs the +value decoded to route. (Canon: "THE GUID IS THE KEY OF KEY-VALUE.") + +--- + +## 3. The 85 / 15 split (the consumer model) + +> The operator's framing: *"in case of lance-graph we would still pull odoo-rs +> but 85 % would be in the OGAR transpile substrate, and the transcode then is +> just a generic compiler-store caller with some adapters."* + +- **85 % — mechanical, minted into OGAR.** Fields, relations, computed values, + validations, the schema. The consumer is a **thin compiler-store caller**: + `compile_graph::

(graph)` → pull `CompiledClass` → render via `ClassView`. +- **15 % — "impossible", a per-language adapter.** Intrusive / stateful / + truly language-specific logic that does not fit a clean mold becomes a + **custom adapter + ClassView + ontological grounding**. Worked example: + `odoo-rs`'s `od-posting` — the GoBD double-entry posting host (gapless + Belegnummer + inalterability hash chain) — stays a hand-written Rust adapter; + *everything else* of `account.move` is minted. + +This is the **Core-First Transcode Doctrine** (`.claude/knowledge` / +`core-first-transcode-doctrine.md`) restated for consumers: mechanical leaf +methods → thin `classid`-keyed adapters that **assume the Core**; intrusive +methods → hand-port; a Core gap → **extend the Core deliberately**, never hack +the adapter. + +### What a thinned consumer looks like (the #2 target) + +``` +odoo-rs (today) odoo-rs (thinned) +───────────────── ────────────────── +od-ontology bespoke Schema+triples od-ontology = a compile_graph:: caller +schema_to_classes → DDL (pulls CompiledClass from the substrate) +od-posting GoBD logic od-posting = unchanged (the 15% adapter) +alignment FIBO/DOLCE seed grounding = resolved late via classid → ClassView → OGIT + + a thin wrapper contract (lance-graph-contract) +``` + +The consumer shrinks to **(import the substrate) + (a compile_graph call) + +(the GoBD adapter) + (a wrapper contract)**. That is the "cost of an import." + +--- + +## 4. Grounding: resolve, don't store + +FIBO / DOLCE / OGIT grounding is **not** stored on the facet or the codebook +rows. The contract is deliberate (`lance-graph-contract`): *"the meta-DTO +resolves; it does not store."* Grounding is resolved **late** via +`classid → ClassView → OGIT registry`: + +- the OGIT hydrator inheritance chain `odoo → fibo-fnd → dolce` + (`lance-graph-ontology::hydrators`), plus `classify_odoo(model)` → DOLCE + category (e.g. `account.move` → `Perdurant`); +- the FIBO pivot (`account.move` ⇒ `fibo:Transaction`) lives in + `od-ontology::alignment::ODOO_SEED` + `odoo-to-fibo.ttl`. + +So **"retain grounding" = keep the `classid` correct** (never ship `0` outside +the bootstrap address). A 16-byte facet suffices for a richly-grounded class +because the grounding is one resolve away, not copied onto every row. (This is +why the `#133` handover's "add `{ogit_uri, dolce_category, fibo_equivalent}` +to codebook rows" gap was **declined** — it fights this design.) + +--- + +## 5. Worked example — `account.move` + +``` +account.move (Odoo Python) + └─ ruff_python_spo ─► ModelGraph { ns: "odoo", model "account_move" } + fields: name(Char), partner_id(Many2one res.partner), + line_ids(One2many account.move.line, inverse move_id), + amount_total(Monetary, compute=_compute_amount, depends line_ids.balance) + ├─ lift_model_graph_python ─► Class "account_move" + │ attributes: [name] + │ associations: [partner_id → BelongsTo res.partner, + │ line_ids → HasMany account.move.line (inverse move_id)] + │ computed_fields: [amount_total ← _compute_amount, depends [line_ids.balance]] + └─ mint_graph:: ─► Facet + facet_classid = 0x0002_0202 (Odoo 0x0002 | commercial_document 0x0202) + ⇒ CompiledClass { class, facet } + + Cross-checks (all probe-verified): + • facet.to_bytes() ≡ lance_graph_contract::FacetCascade(0x0002_0202) (byte-exact) + • canonical_concept_domain(0x0202) == Commerce (routes to the right ClassView) + • grounding resolvable: 0x0202 → fibo:Transaction / DOLCE Perdurant (late, via OGIT) + • the GoBD posting (account.move._post) stays od-posting's Rust adapter (the 15%) +``` + +The **relation-aware** shape (`Many2one` vs `Many2many` vs `One2many`) is only +correct because of the `relation_kind` predicate (ruff#35): `target` + +`inverse_name` alone cannot separate a Many2one from a Many2many. + +--- + +## 6. What is built / what is next + +**Built (this arc):** +- `ruff_python_spo` — Odoo/Python SPO frontend (ruff #34). +- `relation_kind` predicate — Many2one/One2many/Many2many cardinality (ruff #35). +- `lift_model_graph_python` + the `project_odoo_fields` schema projection (OGAR #131/#132). +- **`ogar-from-ruff::mint`** — per-class minting: `mint_graph

`, + `CompiledClass`, `compile_graph_python

` (OGAR #132). + +**Next (the transpiler direction):** +1. **Pull-back emit** — `ogar-emit-` adapters (`CompiledClass → `), + mirroring `ogar-adapter-surrealql`. Plus the thin runtime wrapper-contract + pattern for C#/Python (lance-graph-contract is the Rust reference). +2. **Thin the consumer** — `odoo-rs` collapses to a `compile_graph::` + caller + the `od-posting` GoBD adapter (the 15%). +3. **Scale** — run the `odoo_blueprint` 404 entities through `compile_graph`; + over-cap god-models (`≥ 256` members) branch via the SoC lint + (`ruff_spo_address::soc`), never widen. + +--- + +## 7. For a future session — how to extend (the four moves) + +| To add… | Do this | Convergence is… | +|---|---|---| +| a **source language** | a `ruff__spo` frontend → `ModelGraph`; reuse `lift` + `mint` | automatic (shared IR) | +| a **target language** | an `ogar-emit-` adapter (`CompiledClass → String`) **or** a thin runtime wrapper contract (traits mirroring `lance-graph-contract`) | the consumer reimplements nothing | +| a **concept** | a `class_ids` codebook entry + a `PortSpec` alias | automatic across all ports that map it | +| a **port (app)** | one `impl PortSpec for FooPort` block (NAMESPACE, BRIDGE_ID, APP_PREFIX, aliases) | the app's classes get a render skin for free | + +**Iron rules that bind this surface** (don't relearn the hard way): +- `classid` is **pure address**; the magic is what it resolves to. Neither u16 + half carries behaviour (`ActionDef`/`KausalSpec` is a property of the Core + node, never the address). See `OGAR-CONSUMER-BEST-PRACTICES.md`. +- **Pull, never re-mint.** The codebook is single-source + (`ogar_vocab::class_ids`); a consumer pulls via `*Port::class_id`, never + copies the table or constructs a `*Bridge`. +- **SurrealQL is an adapter, not a spine.** Behaviour flows + producer → OGAR `Class` + `ActionDef` → adapter; never producer → DDL. +- **Resolve, don't store** (grounding) — §4. +- **No serialization in the hot path** (the Firewall, ADR-022/023); the IR is + wire-truth. + +--- + +*Authored alongside the `ogar-from-ruff::mint` per-class minting (OGAR #132). +The pull-in + mint legs are shipped and probe-verified; the pull-back emit and +consumer-thinning legs (§6.1, §6.2) are the next deliverables.* From cb4b3566e0e62f14867dbd1a49101d265ef0e3a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 14:36:43 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat(ogar-from-ruff):=20pull-back=20emit=20?= =?UTF-8?q?=E2=80=94=20Rust=20codegen=20reference=20(CompiledClass=20->=20?= =?UTF-8?q?Rust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transpiler's output leg. `mint` is pull-in (source -> CompiledClass); `emit` is pull-back (CompiledClass -> a target language). Reference target is Rust, mirroring `ogar-adapter-surrealql`'s role for SurrealQL DDL. `emit_rust(&CompiledClass) -> String` renders a rail struct whose fields use the consumer's WRAPPER-CONTRACT types — so "the language just needs a wrapper contract akin to lance-graph" is literal: - scalar attribute -> `OgScalar` - Many2one/HasOne -> `ToOne` - One2many/Many2many-> `ToMany` - the rail address travels as `pub const _CLASSID: u32` - computed fields -> doc lines (the compute behaviour is the "impossible" 15% — it lands as an adapter, not inline codegen) account.move -> `struct AccountMove { name: OgScalar, partner_id: ToOne, line_ids: ToMany }` + ACCOUNT_MOVE_CLASSID = 0x00020202. Scalars emit `OgScalar` uniformly until the `field_type` capture lands (ruff follow-up); the seam doesn't change when it does. Doc (OGAR-TRANSPILE-SUBSTRATE.md) updated: both transpile legs (pull-in lift+mint, pull-back emit) now shipped + probe-verified. Verified via probe (real ogar-vocab + ruff main): 36 tests pass (2 new emit tests), clippy -D warnings clean. Co-Authored-By: Claude --- crates/ogar-from-ruff/src/emit.rs | 178 ++++++++++++++++++++++++++++++ crates/ogar-from-ruff/src/lib.rs | 1 + docs/OGAR-TRANSPILE-SUBSTRATE.md | 23 +++- 3 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 crates/ogar-from-ruff/src/emit.rs diff --git a/crates/ogar-from-ruff/src/emit.rs b/crates/ogar-from-ruff/src/emit.rs new file mode 100644 index 0000000..daf5e76 --- /dev/null +++ b/crates/ogar-from-ruff/src/emit.rs @@ -0,0 +1,178 @@ +//! Pull-back emit — the OGAR transpile substrate's *output* leg. +//! +//! [`mint`](crate::mint) is the pull-in half (source → `CompiledClass`); this +//! is the pull-back half: render a [`CompiledClass`] back into a target +//! language's source. The reference emitter here is **Rust** +//! ([`emit_rust`]); it mirrors the role `ogar-adapter-surrealql` plays for +//! SurrealQL DDL. Other targets (`emit_csharp`, `emit_python`, …) follow the +//! same `&CompiledClass -> String` seam. +//! +//! **The wrapper-contract pivot.** The emitted struct does not inline native +//! types — it uses the consumer's thin wrapper-contract types: `OgScalar` for +//! a column, `ToOne` / `ToMany` for a relation. So "the language just +//! needs to put a wrapper contract akin to lance-graph" is literal: a Rust +//! consumer provides those three aliases (its wrapper contract) and the +//! emitted, rail-shaped class compiles. The `classid` is emitted as a `const` +//! — the rail address travels with the class. +//! +//! Scalar attributes currently emit `OgScalar` uniformly: the Odoo field type +//! (`Char` / `Monetary` / …) is not yet carried on the SPO `Field`, so there +//! is nothing to specialise on. When the `field_type` capture lands (the ruff +//! follow-up), `OgScalar` refines to the mapped concrete type with no change +//! to this seam. + +use ogar_vocab::AssociationKind; + +use crate::mint::CompiledClass; + +/// Emit a [`CompiledClass`] as Rust source: a struct whose fields use the +/// consumer's wrapper-contract types (`OgScalar` / `ToOne` / `ToMany`), +/// prefixed by its rail `classid` const and a facet/concept doc line. +/// Computed fields are emitted as trailing doc lines (the compute behaviour +/// is the "impossible" 15% — it lands as an adapter, not inline codegen). +#[must_use] +pub fn emit_rust(cc: &CompiledClass) -> String { + let ty = pascal_case(&cc.class.name); + let mut out = String::new(); + + out.push_str(&format!( + "/// Rail class `{}` — classid `0x{:08X}` (concept `0x{:04X}`).\n", + cc.class.name, + cc.facet.facet_classid(), + cc.facet.facet_classid() as u16, + )); + out.push_str(&format!( + "pub const {}_CLASSID: u32 = 0x{:08X};\n\n", + screaming_snake(&cc.class.name), + cc.facet.facet_classid(), + )); + + out.push_str(&format!("pub struct {ty} {{\n")); + for attr in &cc.class.attributes { + out.push_str(&format!(" pub {}: OgScalar,\n", attr.name)); + } + for assoc in &cc.class.associations { + let target = pascal_case(assoc.class_name.as_deref().unwrap_or(&assoc.name)); + let field_ty = match assoc.kind { + AssociationKind::BelongsTo | AssociationKind::HasOne => format!("ToOne<{target}>"), + AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => { + format!("ToMany<{target}>") + } + // A future AssociationKind defaults to a single typed reference. + _ => format!("ToOne<{target}>"), + }; + out.push_str(&format!(" pub {}: {field_ty},\n", assoc.name)); + } + out.push_str("}\n"); + + for c in &cc.class.computed_fields { + out.push_str(&format!( + "// computed: {} <- {}({})\n", + c.field, + c.compute_method, + c.depends.join(", "), + )); + } + + out +} + +/// `account.move` / `account_move` → `AccountMove`. Treats both `.` and `_` +/// as word separators (Odoo dotted comodels and underscore-normalised model +/// names both arrive here). +fn pascal_case(name: &str) -> String { + name.split(['.', '_']) + .filter(|seg| !seg.is_empty()) + .map(|seg| { + let mut chars = seg.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + chars.as_str() + }) + }) + .collect() +} + +/// `account_move` → `ACCOUNT_MOVE` (for the `*_CLASSID` const name). +fn screaming_snake(name: &str) -> String { + name.split(['.', '_']) + .filter(|seg| !seg.is_empty()) + .map(str::to_uppercase) + .collect::>() + .join("_") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mint::compile_graph_python; + use ogar_vocab::ports::OdooPort; + use ruff_spo_triplet::{Field, Function, Model, ModelGraph}; + + fn account_move_graph() -> ModelGraph { + let mut m = Model::new("account_move"); + m.fields.push(Field { + name: "name".to_string(), + ..Default::default() + }); + m.fields.push(Field { + name: "partner_id".to_string(), + target: Some("res.partner".to_string()), + relation_kind: Some("many2one".to_string()), + ..Default::default() + }); + m.fields.push(Field { + name: "line_ids".to_string(), + target: Some("account.move.line".to_string()), + inverse_name: Some("move_id".to_string()), + relation_kind: Some("one2many".to_string()), + ..Default::default() + }); + m.fields.push(Field { + name: "amount_total".to_string(), + emitted_by: Some("_compute_amount".to_string()), + depends_on: vec!["line_ids.balance".to_string()], + ..Default::default() + }); + m.functions.push(Function { + name: "_compute_amount".to_string(), + reads: Vec::new(), + raises: Vec::new(), + traverses: Vec::new(), + }); + let mut g = ModelGraph::new("odoo"); + g.models.push(m); + g + } + + #[test] + fn emits_rail_struct_with_wrapper_contract_types() { + let cc = &compile_graph_python::(&account_move_graph())[0]; + let rust = emit_rust(cc); + + // The rail address travels as a const. + assert!(rust.contains("pub const ACCOUNT_MOVE_CLASSID: u32 = 0x00020202;")); + // The struct is PascalCase. + assert!(rust.contains("pub struct AccountMove {")); + // Scalar -> the wrapper's OgScalar. + assert!(rust.contains("pub name: OgScalar,")); + // Many2one -> ToOne; One2many -> ToMany. + assert!( + rust.contains("pub partner_id: ToOne,"), + "got:\n{rust}" + ); + assert!( + rust.contains("pub line_ids: ToMany,"), + "got:\n{rust}" + ); + // Computed behaviour is a doc line (the 15% lands as an adapter). + assert!(rust.contains("// computed: amount_total <- _compute_amount(line_ids.balance)")); + } + + #[test] + fn pascal_case_handles_dotted_and_underscored() { + assert_eq!(pascal_case("account.move.line"), "AccountMoveLine"); + assert_eq!(pascal_case("account_move"), "AccountMove"); + assert_eq!(pascal_case("res.partner"), "ResPartner"); + assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE"); + } +} diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index ca90a54..7f23c13 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -64,6 +64,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod emit; pub mod mint; use ogar_vocab::{ diff --git a/docs/OGAR-TRANSPILE-SUBSTRATE.md b/docs/OGAR-TRANSPILE-SUBSTRATE.md index e44cd95..98ea030 100644 --- a/docs/OGAR-TRANSPILE-SUBSTRATE.md +++ b/docs/OGAR-TRANSPILE-SUBSTRATE.md @@ -230,11 +230,20 @@ correct because of the `relation_kind` predicate (ruff#35): `target` + - `lift_model_graph_python` + the `project_odoo_fields` schema projection (OGAR #131/#132). - **`ogar-from-ruff::mint`** — per-class minting: `mint_graph

`, `CompiledClass`, `compile_graph_python

` (OGAR #132). +- **`ogar-from-ruff::emit`** — the pull-back **codegen** leg, reference + target Rust: `emit_rust(&CompiledClass) -> String` renders a rail struct + whose fields use the consumer's wrapper-contract types (`OgScalar` / + `ToOne` / `ToMany`) + a `*_CLASSID` const (OGAR #132). + `account.move → struct AccountMove { name: OgScalar, partner_id: + ToOne, line_ids: ToMany }`. **Next (the transpiler direction):** -1. **Pull-back emit** — `ogar-emit-` adapters (`CompiledClass → `), - mirroring `ogar-adapter-surrealql`. Plus the thin runtime wrapper-contract - pattern for C#/Python (lance-graph-contract is the Rust reference). +1. **Pull-back emit, breadth + depth** — `emit_csharp` / `emit_python` + targets on the same `&CompiledClass -> String` seam; refine `OgScalar` + to mapped concrete types once the `field_type` capture lands (ruff + follow-up); extract the family into dedicated `ogar-emit-` crates + mirroring `ogar-adapter-surrealql`. The runtime wrapper-contract mode + (lance-graph-contract for Rust) is the C#/Python sibling. 2. **Thin the consumer** — `odoo-rs` collapses to a `compile_graph::` caller + the `od-posting` GoBD adapter (the 15%). 3. **Scale** — run the `odoo_blueprint` 404 entities through `compile_graph`; @@ -267,6 +276,8 @@ correct because of the `relation_kind` predicate (ruff#35): `target` + --- -*Authored alongside the `ogar-from-ruff::mint` per-class minting (OGAR #132). -The pull-in + mint legs are shipped and probe-verified; the pull-back emit and -consumer-thinning legs (§6.1, §6.2) are the next deliverables.* +*Authored alongside the `ogar-from-ruff::mint` per-class minting + the +`ogar-from-ruff::emit` Rust pull-back reference (OGAR #132). Both transpile +legs — pull-in (lift + mint) and pull-back (emit) — are shipped and +probe-verified; breadth (more target languages, type-capture refinement) and +the consumer-thinning leg (§6.2) are the next deliverables.*