From 7eb1411a8faf27099c6ae2f24927fefb885cb42f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 07:24:40 +0000 Subject: [PATCH] feat(ontology): virtually_overrides as a computed ClassView relation (wishlist last item) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the odoo-rs wishlist's last item — `virtually_overrides` — the Core-correct way: a DERIVED ClassView method-resolution relation, NOT a sixth `spo_enrich.py` harvest predicate. Consistent with the Core-first correction (E-ODOO-CORE-FIRST-STRUCTURAL): a fact an AST pass reads lives in the typed Core; a relation the ClassView derives lives in the resolver; neither is a flat-ndjson predicate. # The relation Per the doctrine the (has_function / inherits_from / virtually_overrides) triad is the ClassView method-resolution manifest. has_function + inherits_from are facts; virtually_overrides is the derivation: `M.m` overrides `B.m` iff M declares m, B is reachable up M's `_inherit` chain, and B also declares m. # What lands — `odoo_blueprint/mro.rs` - `resolve_overrides(methods_of, bases_of)` — pure resolver over an abstract manifest. Nearest-base-wins BFS up the _inherit chain, cycle-guarded, deterministic. Equals Python C3 for the linear mixin chains Odoo uses; documented breadth-first approximation for diamonds. - `manifest_from_curated_core()` — wires the typed Core (`curated_entities()` methods + `structural::INHERITS`) as the authoritative manifest source. - `project_virtually_overrides()` — emits `(odoo:., virtually_overrides, odoo:.)` for consumers that want the corpus shape. - 7 tests: direct override, no-false-positive (method only on child), nearest-base-wins, transitive skip, cycle guard, projection shape, typed-core smoke. # Manifest source is the caller's choice; resolver is identical The curated-core manifest is authoritative but narrow — most curated models inherit UNCURATED mixins, so the resolved set is honestly small today (the smoke test asserts the call is total, not that overrides exist). A consumer holding the SPO corpus builds the same two maps from has_function + inherits_from (388 ObjectTypes incl. mixins) and gets the full resolution from the same resolver. Same Curated-leg / Extracted-leg convergence the structural correction established, for the derived relation. # Feeds the #531 ClassView This resolution is the method-resolution layer the contract-side `RegistryClassView` (D-CLS-RES, #531) does not yet carry — the ClassView resolves field-sets via FieldMask but had no MRO/method surface. EPIPHANIES: prepend E-ODOO-VIRTUALLY-OVERRIDES-COMPUTED. Wishlist is now fully home-correct; the predicate-bolt-on cadence is retired. # Tests cargo test -p lance-graph-ontology --lib mro : 7/7 cargo clippy -p lance-graph-ontology --lib : clean on mro.rs --- .claude/board/EPIPHANIES.md | 10 + .../src/odoo_blueprint/mod.rs | 6 + .../src/odoo_blueprint/mro.rs | 285 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 crates/lance-graph-ontology/src/odoo_blueprint/mro.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 0d4ad881..97abbec5 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,13 @@ +## 2026-06-18 — E-ODOO-VIRTUALLY-OVERRIDES-COMPUTED — the wishlist's last item is a *derived* ClassView relation, not harvest predicate #6 + +**Status:** FINDING. `virtually_overrides` — the one open odoo-rs wishlist item — lands as a **computed** Core/ClassView capability (`odoo_blueprint::mro`), closing the wishlist consistently with the Core-first correction (E-ODOO-CORE-FIRST-STRUCTURAL) directly above. Per the doctrine the `(has_function / inherits_from / virtually_overrides)` triad is the ClassView method-resolution manifest: `has_function` + `inherits_from` are *facts*; `virtually_overrides` is the **derivation** (`M.m` overrides `B.m` iff `M` declares `m`, `B` is up `M`'s `_inherit` chain, and `B` also declares `m`). Adding a `virtually_overrides` AST pass to `spo_enrich.py` would have been the drift; computing it from the manifest is the Core-correct move. + +**Shape.** `resolve_overrides(methods_of, bases_of)` is a pure resolver over an abstract manifest — nearest-base-wins BFS up the `_inherit` chain, cycle-guarded, deterministic; equals Python C3 for the linear mixin chains Odoo uses, documented breadth-first approximation for diamonds. `manifest_from_curated_core()` wires the typed Core (`curated_entities()` methods + `structural::INHERITS`) as the authoritative source; a consumer holding the SPO corpus builds the same two maps from `has_function`+`inherits_from` (388 ObjectTypes incl. mixins) and gets the full resolution — identical resolver, broader manifest. `project_virtually_overrides` emits `(odoo:., virtually_overrides, odoo:.)` for consumers wanting the corpus shape. 7 tests. + +**Honest scope.** The curated-only manifest resolves a *small* set today — most curated models inherit *uncurated* mixins (`mail.activity.mixin`, `sequence.mixin`), so few overrides have both ends present. Not fabricated: the resolver is total and the breadth grows as mixins are curated OR the SPO manifest is supplied. This is the same Curated-leg / Extracted-leg convergence the structural correction established, now for the derived relation. + +**Wishlist status:** all six items are now home-correct — P0/P1/P1b/P2/P3 as typed-Core facts + Extracted-leg breadth (#523/#526/#527/#530/#532), and `virtually_overrides` as this computed ClassView relation. None of them is a standalone flat-ndjson harvest predicate; the predicate-bolt-on cadence is fully retired. Cross-ref: `mro.rs` module doc; E-ODOO-CORE-FIRST-STRUCTURAL; the #531 `RegistryClassView` (the contract-side ClassView this resolution feeds). + ## 2026-06-18 — E-ODOO-CORE-FIRST-STRUCTURAL — structural Odoo facts belong in the typed OGAR Core, NOT the SPO harvest; the predicate-bolt-on cadence was drift **Status:** FINDING (Core-first correction; operator-confirmed). **This entry supersedes the *framing* of the four prior `E-ODOO-SPO-*` / `E-ODOO-EXTRACT-*` entries below — read it before extending their pattern.** Those entries are factually correct about what shipped, but a session reading them in sequence would conclude "the way to satisfy the odoo-rs wishlist is to add another `spo_enrich.py` predicate." That conclusion is the self-fulfilling drift this entry reverses. diff --git a/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs b/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs index 2a34d0fe..ebf1f272 100644 --- a/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs +++ b/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs @@ -100,6 +100,12 @@ pub mod op_emitter; /// Extracted-leg breadth feeder. See the module doc for the full rationale. pub mod structural; +/// `virtually_overrides` — the ClassView method-resolution relation, +/// **computed** from the `has_function` + `inherits_from` manifest, NOT +/// harvested. The Core-correct home for the wishlist's last item: a derived +/// MRO-precedence resolution, not `spo_enrich` predicate #6. +pub mod mro; + // ─── Top-level entity ───────────────────────────────────────────────────── /// Which ORM base class the entity inherits from. diff --git a/crates/lance-graph-ontology/src/odoo_blueprint/mro.rs b/crates/lance-graph-ontology/src/odoo_blueprint/mro.rs new file mode 100644 index 00000000..0c358f77 --- /dev/null +++ b/crates/lance-graph-ontology/src/odoo_blueprint/mro.rs @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! `virtually_overrides` — the ClassView method-resolution relation, **computed** +//! from the manifest, never harvested. +//! +//! # Why this is NOT `spo_enrich` predicate #6 (Core-first, 2026-06-18) +//! +//! The Core-first correction (`EPIPHANIES.md` E-ODOO-CORE-FIRST-STRUCTURAL) +//! drew the line: a structural fact that an AST pass *reads* belongs in the +//! typed Core, and a relation that the ClassView *derives* belongs in the +//! resolver — neither is a new flat-ndjson harvest predicate. +//! +//! `virtually_overrides` is the second kind. Per the doctrine, the +//! `(has_function / inherits_from / virtually_overrides)` triad is the +//! **ClassView method-resolution manifest**: `has_function` (which methods a +//! class declares) and `inherits_from` (the `_inherit` chain) are *facts*; +//! `virtually_overrides` is the **derivation** — model `M`'s method `m` +//! virtually-overrides base `B`'s `m` iff `M` declares `m`, `B` is reachable +//! up `M`'s `_inherit` chain, and `B` also declares `m`. This module computes +//! that relation. Adding a `virtually_overrides` AST pass to `spo_enrich.py` +//! would be the drift; computing it here is the Core-correct move. +//! +//! # Manifest source is the caller's choice; the resolver is pure +//! +//! [`resolve_overrides`] takes an abstract manifest (`methods_of` + +//! `bases_of`) so the same resolver serves either source: +//! +//! - the **typed Core** ([`manifest_from_curated_core`]) — authoritative, but +//! resolves an override only where *both* the child and the shadowed base +//! are curated `OdooEntity`s. Today most curated models inherit *uncurated* +//! mixins (`mail.activity.mixin`, `sequence.mixin`), so the curated-only +//! manifest resolves a small set — honest, not fabricated. +//! - the **SPO harvest manifest** — a consumer holding the corpus builds the +//! same two maps from `has_function` + `inherits_from` triples (388 +//! ObjectTypes incl. mixins) and gets the full resolution. The resolver +//! code is identical; only the manifest breadth differs. +//! +//! # Precedence +//! +//! The shadowed base is the **nearest** one up a breadth-first walk of the +//! `_inherit` chain (sorted tie-break for determinism). For the *linear* +//! mixin chains Odoo uses in practice this equals Python's C3 MRO; for +//! diamonds it is the documented breadth-first approximation, not full C3. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use super::structural::SpoTriple; + +/// One resolved method-override: `child_model.method` virtually-overrides the +/// same-named method on `base_model`, reached up the `_inherit` chain. All +/// names are underscored (corpus IRI local-part convention). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MethodOverride { + /// Overriding model (underscored, e.g. `account_move`). + pub child_model: String, + /// Method name shared by child and base (e.g. `_post`). + pub method: String, + /// Nearest shadowed base model (underscored, e.g. `sequence_mixin`). + pub base_model: String, +} + +/// The method-resolution manifest, keyed by underscored model name: +/// `methods_of[m]` = the set of method names model `m` declares +/// (`has_function`); `bases_of[m]` = model `m`'s ordered `_inherit` bases +/// (`inherits_from`). +pub type MethodsOf = BTreeMap>; +/// See [`MethodsOf`]. +pub type BasesOf = BTreeMap>; + +/// Compute `virtually_overrides` from the manifest. For every model `M` with +/// methods and every method `m` it declares, walk `M`'s `_inherit` chain +/// breadth-first and emit one override against the **nearest** base that also +/// declares `m`. Cycle-guarded; deterministic (sorted output). +#[must_use] +pub fn resolve_overrides(methods_of: &MethodsOf, bases_of: &BasesOf) -> Vec { + let mut out: Vec = Vec::new(); + + for (child, child_methods) in methods_of { + for method in child_methods { + if let Some(base) = nearest_base_declaring(child, method, methods_of, bases_of) { + out.push(MethodOverride { + child_model: child.clone(), + method: method.clone(), + base_model: base, + }); + } + } + } + + out.sort(); + out.dedup(); + out +} + +/// BFS up `start`'s `_inherit` chain; return the first base (sorted tie-break +/// within a level) that declares `method`. `None` if no base declares it. +fn nearest_base_declaring( + start: &str, + method: &str, + methods_of: &MethodsOf, + bases_of: &BasesOf, +) -> Option { + let mut seen: BTreeSet = BTreeSet::new(); + seen.insert(start.to_string()); + // Frontier holds (model) at increasing chain distance; a BTreeSet per level + // gives the sorted tie-break, then feeds the next level. + let mut frontier: VecDeque = bases_of + .get(start) + .into_iter() + .flatten() + .cloned() + .collect(); + + while let Some(node) = frontier.pop_front() { + if !seen.insert(node.clone()) { + continue; // cycle / diamond re-visit guard + } + if methods_of.get(&node).is_some_and(|ms| ms.contains(method)) { + return Some(node); + } + // enqueue this node's bases (sorted for determinism), depth-extending + if let Some(bs) = bases_of.get(&node) { + let mut next: Vec<&String> = bs.iter().collect(); + next.sort(); + for b in next { + if !seen.contains(b) { + frontier.push_back(b.clone()); + } + } + } + } + None +} + +/// Project resolved overrides into SPO triples +/// `(odoo:., virtually_overrides, odoo:.)`. +#[must_use] +pub fn project_virtually_overrides(overrides: &[MethodOverride]) -> Vec { + overrides + .iter() + .map(|o| { + ( + format!("odoo:{}.{}", o.child_model, o.method), + "virtually_overrides", + format!("odoo:{}.{}", o.base_model, o.method), + ) + }) + .collect() +} + +/// Build the manifest from the **typed Core** — `curated_entities()` methods +/// (`has_function`) + [`super::structural::INHERITS`] (`inherits_from`). +/// Authoritative but narrow: resolves an override only where both ends are +/// curated. Model + base names are underscored to match the corpus convention. +#[must_use] +pub fn manifest_from_curated_core() -> (MethodsOf, BasesOf) { + let underscore = |s: &str| s.replace('.', "_"); + + let mut methods_of: MethodsOf = BTreeMap::new(); + for ent in super::class_signature::curated_entities() { + let m = underscore(ent.model_name); + let set = methods_of.entry(m).or_default(); + for meth in ent.methods { + set.insert(meth.name.to_string()); + } + } + + let mut bases_of: BasesOf = BTreeMap::new(); + for row in super::structural::INHERITS { + bases_of.insert( + underscore(row.model), + row.bases.iter().map(|b| underscore(b)).collect(), + ); + } + + (methods_of, bases_of) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn methods(pairs: &[(&str, &[&str])]) -> MethodsOf { + pairs + .iter() + .map(|(m, ms)| (m.to_string(), ms.iter().map(|s| s.to_string()).collect())) + .collect() + } + fn bases(pairs: &[(&str, &[&str])]) -> BasesOf { + pairs + .iter() + .map(|(m, bs)| (m.to_string(), bs.iter().map(|s| s.to_string()).collect())) + .collect() + } + + #[test] + fn child_overrides_direct_base() { + let m = methods(&[("child", &["_post", "own"]), ("base", &["_post"])]); + let b = bases(&[("child", &["base"])]); + let ov = resolve_overrides(&m, &b); + assert_eq!( + ov, + vec![MethodOverride { + child_model: "child".into(), + method: "_post".into(), + base_model: "base".into(), + }] + ); + } + + #[test] + fn method_only_on_child_is_not_an_override() { + // `own` exists only on child → no base shadows it → no override. + let m = methods(&[("child", &["own"]), ("base", &["_post"])]); + let b = bases(&[("child", &["base"])]); + assert!(resolve_overrides(&m, &b).is_empty()); + } + + #[test] + fn nearest_base_wins_over_farther_base() { + // child → mid → far ; all declare _x. Override targets `mid` (nearest). + let m = methods(&[ + ("child", &["_x"]), + ("mid", &["_x"]), + ("far", &["_x"]), + ]); + let b = bases(&[("child", &["mid"]), ("mid", &["far"])]); + let ov = resolve_overrides(&m, &b); + assert_eq!(ov.len(), 2, "child→mid and mid→far both resolve"); + // child's override target is the nearer `mid`, not `far`. + let child = ov.iter().find(|o| o.child_model == "child").unwrap(); + assert_eq!(child.base_model, "mid"); + } + + #[test] + fn transitive_skip_when_nearest_lacks_the_method() { + // child → mid → far ; only `far` declares _x. child overrides far. + let m = methods(&[("child", &["_x"]), ("mid", &["other"]), ("far", &["_x"])]); + let b = bases(&[("child", &["mid"]), ("mid", &["far"])]); + let ov = resolve_overrides(&m, &b); + let child = ov.iter().find(|o| o.child_model == "child").unwrap(); + assert_eq!(child.base_model, "far"); + } + + #[test] + fn cycle_in_chain_is_guarded() { + // a → b → a (pathological). Must terminate; a._x overrides b._x. + let m = methods(&[("a", &["_x"]), ("b", &["_x"])]); + let b = bases(&[("a", &["b"]), ("b", &["a"])]); + let ov = resolve_overrides(&m, &b); // must not hang + assert!(ov.iter().any(|o| o.child_model == "a" && o.base_model == "b")); + } + + #[test] + fn projection_shape_is_method_iri_both_sides() { + let ov = vec![MethodOverride { + child_model: "account_move".into(), + method: "_post".into(), + base_model: "sequence_mixin".into(), + }]; + let triples = project_virtually_overrides(&ov); + assert_eq!( + triples, + vec![( + "odoo:account_move._post".to_string(), + "virtually_overrides", + "odoo:sequence_mixin._post".to_string(), + )] + ); + } + + #[test] + fn typed_core_manifest_builds_and_resolver_runs() { + // Smoke: the curated-core manifest builds and the resolver terminates. + // The resolved set is honestly small/empty today (curated models mostly + // inherit UNCURATED mixins, so few overrides have both ends present) — + // a fuller SPO manifest resolves more. We assert the manifest is + // non-trivial and the call is total, not that overrides exist. + let (m, b) = manifest_from_curated_core(); + assert!(!m.is_empty(), "curated core declares methods"); + assert!(b.contains_key("account_move"), "account_move has _inherit bases"); + let _ = resolve_overrides(&m, &b); // terminates, deterministic + } +}