From b12c9a6e6ff5f60191e48df2141e4ae068df9440 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 20:47:10 +0000 Subject: [PATCH] docs(od-ontology): pull SPO predicate schema from lance-graph + name the compiler-AST frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 repatriation per REPATRIATION-FRAME.md: pulled the rich predicate schema documentation from `lance_graph::graph::spo::odoo_ontology` (where it was wrong-altitude — generic graph spine carrying a per-app vocabulary) into `triple.rs` where the consumer that USES the vocabulary owns its docs. And operator-named the deeper frame this pull surfaces: "lance-graph on OGAR is acting like a compiler, so the AST needs to think like that" Made explicit in triple.rs head: SPO corpus = AST (typed nodes = 13 predicates, provenance = 4 truth bands); odoo-rs = language frontend; OGAR Class/ActionDef = IR; lance-graph + adapters = middle/back end. Adding a 14th predicate is a language change, not a doc update. Slices reference externs resolved by the symbol table (OGAR ClassView), not in-slice. Pulled into the module doc: - 13-predicate × 3-S/O-shape × provenance-band schema table - The 4 truth-value provenance bands (corrected from lance-graph's incomplete 3-band claim — the corpus actually carries (1.0, 1.0) declaration AND (1.0, 0.95) membership; the new test caught it) - The "a + b → c through d?" Foundry query pattern (what the corpus is FOR) - Two enrichment layers (target/inverse_name; deep reads_field) + the _inherit-only / multi-emitter corrections New `tests/corpus_schema_pin.rs` — 10 invariant pins, all green against slice-1 (account_move) and slice-2 corpora. Catches: undocumented predicate appearing, truth band outside the four, structural band leaking into a non-structural predicate, inherits_from / reads_field self-loops, target carrying odoo: prefix, validation_kind drift, raises in wrong namespace, + the canonical convergence-pin deep read (_compute_amount cross-model). Three pins SHIFTED in scope from the lance-graph original (the AST-view made the demands the slice corpora can't meet): inherits_from base is "well- formed extern" not "declared in slice"; target object is "raw, no odoo: prefix" not "must be dotted" (single-word comodels like `website` exist). Full-corpus integrity claims belong on the full corpus, not slices. Additive — lance-graph's odoo_ontology.rs untouched (it's the duplicate now, to be subtracted in a later phase). 39 lib + 7 bin + 10 corpus-pin + all integration green; clippy 0; fmt-clean. Co-Authored-By: Claude Opus 4.8 --- crates/od-ontology/src/triple.rs | 157 +++++++++- crates/od-ontology/tests/corpus_schema_pin.rs | 277 ++++++++++++++++++ 2 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 crates/od-ontology/tests/corpus_schema_pin.rs diff --git a/crates/od-ontology/src/triple.rs b/crates/od-ontology/src/triple.rs index afdd467..dffec2a 100644 --- a/crates/od-ontology/src/triple.rs +++ b/crates/od-ontology/src/triple.rs @@ -1,13 +1,160 @@ -//! The SPO triple corpus — input to the ontology projection. +//! The SPO triple corpus — the **AST** input to the OGAR compiler. //! -//! Byte-identical shape to `lance_graph::graph::spo::odoo_ontology` (the -//! `{"s","p","o","f","c"}` ndjson) and to `ruff_spo_triplet::ndjson`. The -//! corpus is produced once by the Python frontend (`ruff_python_dto_check` + -//! `odoo-blueprint-extractor`); this crate only *reads* it. +//! # The compiler frame (operator-named 2026-06-22) +//! +//! `lance-graph` on top of OGAR is acting like a **compiler**, and the SPO +//! corpus is the **AST** that compiler consumes. The implication every +//! consumer of this module must hold: the AST has to *think like a compiler +//! input* — typed nodes (the 13 predicates below), provenance on every node +//! (the four truth bands below), and references that are well-formed even +//! when they resolve outside the current compilation unit ("externs", +//! resolved by the symbol table = OGAR `ClassView`, not by the slice). +//! +//! The pipeline: +//! +//! ```text +//! Odoo source ─[ruff_python_dto_check + odoo-blueprint-extractor]→ AST (this corpus) +//! │ +//! │ frontend: parse_ndjson (this module) +//! ▼ +//! Vec (the typed AST) +//! │ +//! │ lowering passes (sibling modules): +//! │ schema_to_classes → ogar_vocab::Class (structure) +//! │ corpus_to_actions → ogar_vocab::ActionDef (behaviour) +//! ▼ +//! OGAR IR +//! │ +//! │ codegen (egress adapters): +//! │ ogar-adapter-surrealql → DEFINE TABLE / FIELD +//! │ ogar-adapter-ttl → TTL +//! │ (future) ogar-from-odoo → reusable lifter +//! ▼ +//! Target text / runtime +//! ``` +//! +//! odoo-rs is the **Odoo language frontend** of that compiler (the per-source +//! glue); OGAR `Class`/`ActionDef` is the **IR**; lance-graph + the adapters +//! are the **compiler middle/back end**. The predicate vocabulary below IS +//! the AST node-type set — adding a 14th predicate is a language change, not +//! a documentation update. +//! +//! Byte-identical shape to `ruff_spo_triplet::ndjson` and (until the +//! repatriation completes) to `lance_graph::graph::spo::odoo_ontology` — see +//! `specs/REPATRIATION-FRAME.md`. The corpus is produced once by the Python +//! frontend (`ruff_python_dto_check` + `odoo-blueprint-extractor`); this crate +//! only *reads* it. **The schema below is the consumer-side source of truth.** //! //! IRI shape: `odoo:.` where the single dot separates model //! from member, and dotted *dependency paths* (`account_move.line_ids.balance`) //! are emitted verbatim — the cross-record reactive signal. +//! +//! # Triple schema (13 predicates × 3 provenance bands) +//! +//! | predicate | subject | object | provenance | +//! | --- | --- | --- | --- | +//! | `rdf:type` | `odoo:` | `ogit:ObjectType` | structural | +//! | `rdf:type` | `odoo:.` | `ogit:Property` | structural | +//! | `rdf:type` | `odoo:.` | `ogit:Function` | structural | +//! | `has_function` | `odoo:` | `odoo:.` | structural | +//! | `emitted_by` | `odoo:.` | `odoo:.` | body write (authoritative) | +//! | `depends_on` | `odoo:.` | `odoo:.` | `@api.depends` arg (authoritative) | +//! | `reads_field` | `odoo:.` | `odoo:.` | body read (inferred) | +//! | `raises` | `odoo:.` | `exc:` | body raise (authoritative) | +//! | `traverses_relation` | `odoo:.` | `odoo:.` | body for-loop (inferred) | +//! | `target` | `odoo:.` | `""` | relational comodel (declared) | +//! | `inverse_name` | `odoo:.` | `""` | One2many/inverse (declared) | +//! | `inherits_from` | `odoo:` | `odoo:` | `_inherit`/`_inherits` base (declared) | +//! | `validation_kind` | `odoo:.` | `""` | `@api.constrains` body pattern (inferred) | +//! | `selection_value` | `odoo:.` | `""` | `fields.Selection` enum key (declared) | +//! +//! ## Truth-value provenance bands +//! +//! NARS `(frequency, confidence)` carries the provenance — **four** bands +//! pinned by the corpus regression test: +//! +//! - **structural-declaration** `(1.0, 1.0)` — `rdf:type`. The class +//! definition itself declares the node's kind; certain. +//! - **structural-membership** `(1.0, 0.95)` — `has_function`. The class +//! declares the function as a member; certain *that* the function exists, +//! slightly less certain whether the harvest's enumeration is exhaustive +//! (a per-emitter or partial extract may legitimately omit some). +//! - **decorator / body-authoritative** `(0.95, 0.9)` — `emitted_by`, +//! `depends_on`, `raises`, `target`, `inverse_name`, `inherits_from`, +//! `selection_value`. Lifted from a decorator argument or an unambiguous +//! AST write/raise/relation. +//! - **body-inferred** `(0.85, 0.75)` — `reads_field`, `traverses_relation`, +//! `validation_kind`. Inferred from method-body AST patterns where the +//! match is heuristic, not certain. +//! +//! Downstream truth-aware queries filter by expectation; a hit on a +//! structural edge outweighs three hits on body-inferred edges. +//! +//! # The "a + b → c through d?" Foundry query (what the corpus is FOR) +//! +//! The Foundry compute graph answers *"which field `c` does method `d` emit +//! when inputs `a` and `b` change?"* by composing two reverse `depends_on` +//! lookups + one `emitted_by` lookup: +//! +//! ```text +//! {c : (c depends_on a) ∧ (c depends_on b)} then {d : (c emitted_by d)} +//! ``` +//! +//! This is a **graph deduction over the loaded triples** — not a similarity +//! search, not an LLM call. It is the structural read of Odoo's reactive +//! compute graph, and the universal pattern any ORM-with-lifecycle exposes +//! once the SPO vocabulary above is canonical. +//! +//! # Enrichment layers (additive over the base extraction) +//! +//! Two predicate families layer on top of the base AST extraction (the +//! `odoo-blueprint-extractor`'s `spo_enrich` pass): +//! +//! - **`target` / `inverse_name`** — for every relational field +//! (Many2one / One2many / Many2many / Reference) whose comodel resolves from +//! the Odoo source, a sibling triple keyed by the relation IRI carries the +//! *raw* dotted comodel name (e.g. +//! `(odoo:account_move.line_ids, target, "account.move.line")` + +//! `(…, inverse_name, "move_id")`). This is the cross-language analog of +//! ruff#18's `(WorkPackage.owner, class_name, "User")` — same shape, Odoo +//! side. It resolves the phantom-target case where `invoice_line_ids` would +//! otherwise lift to a `record` that doesn't exist (the real +//! comodel is `account.move.line`). +//! - **deep `reads_field`** — each `@api.depends('rel.leaf', …)` whose `rel` +//! is a relational field is resolved through the `target` map and the +//! transitive read lifted onto the field's emitting method: e.g. +//! `(odoo:account_move._compute_amount, reads_field, +//! odoo:account_move_line.amount_residual)` is emitted *in addition to* the +//! shallow relation read. This surfaces the cross-model recompute-ordering +//! edge that the surface-only corpus would leave invisible to +//! [`crate::RecomputeDag`]. +//! +//! ## `_inherit`-only extension classes +//! +//! Relational fields declared on an extension class +//! (`_inherit = "account.move"` with no `_name` — the common Odoo extension +//! form) still get their `target` / `inverse_name` lifted. Without this +//! correction the extension's fields would be dropped silently. +//! +//! ## Multi-emitter deep reads +//! +//! A field emitted by more than one method (e.g. `stock_move.quantity` is +//! emitted by both `_compute_quantity` AND `_onchange_product_uom_qty`) has +//! its deep `reads_field` lifted onto **every** emitter, not just the last — +//! otherwise the recompute-ordering edge drops for whichever emitter the +//! enrichment skipped. +//! +//! # Repatriation note +//! +//! The schema above used to live solely in +//! `lance_graph::graph::spo::odoo_ontology` (the generic graph spine — wrong +//! altitude per `specs/REPATRIATION-FRAME.md`). Pulled here in Phase 1: the +//! consumer that *uses* the SPO vocabulary now owns its documentation. A +//! later phase pushes the universal SPO predicates (`has_function`, +//! `emitted_by`, `depends_on`, `reads_field`, `raises`, `target`) to OGAR / +//! ruff as the universal cross-language vocabulary, leaving Odoo-specific +//! predicates (`validation_kind`, `inverse_name`, `inherits_from`, +//! `selection_value`, `traverses_relation`) here as muscle memory. use serde::Deserialize; diff --git a/crates/od-ontology/tests/corpus_schema_pin.rs b/crates/od-ontology/tests/corpus_schema_pin.rs new file mode 100644 index 0000000..3e75357 --- /dev/null +++ b/crates/od-ontology/tests/corpus_schema_pin.rs @@ -0,0 +1,277 @@ +//! Corpus schema regression — pins the **invariants** of the SPO triple +//! corpus's shape, not specific counts. The counts drift across corpus +//! revisions (lance-graph PR #523 added 935 deep `reads_field` lifts, #526 +//! added 166 `inherits_from` + 247 `validation_kind`, …); the invariants +//! below do not. +//! +//! This is the consumer-side pin on the predicate schema documented in +//! `triple.rs` (pulled from `lance_graph::graph::spo::odoo_ontology` per +//! `specs/REPATRIATION-FRAME.md` Phase 1). It catches: +//! +//! - a NEW predicate appearing without being added to the documented set (the +//! 13-predicate vocabulary is the contract; a 14th is a schema drift event); +//! - a truth value falling outside the three documented provenance bands; +//! - structural inconsistencies (`inherits_from` self-loops, `reads_field` +//! self-loops, `target` carrying an unexpected `odoo:` prefix). +//! +//! Runs against BOTH shipped corpora (`account_move.spo.ndjson` slice-1 and +//! `slice_2.spo.ndjson` — every invariant must hold on both). + +use od_ontology::{parse_ndjson, Triple}; + +const SLICE_1: &str = include_str!("../../../data/account_move.spo.ndjson"); +const SLICE_2: &str = include_str!("../../../data/slice_2.spo.ndjson"); + +/// The 13 predicates documented in `triple.rs`. Any predicate appearing in +/// the corpus that is NOT in this list is a schema-drift event the schema +/// docs must absorb first (or the harvester must stop emitting). +const DOCUMENTED_PREDICATES: &[&str] = &[ + "rdf:type", + "has_function", + "emitted_by", + "depends_on", + "reads_field", + "raises", + "traverses_relation", + "target", + "inverse_name", + "inherits_from", + "validation_kind", + "selection_value", +]; + +/// Tolerance for the truth-value provenance bands. NARS values are stored as +/// `f32`; we compare with a generous epsilon so a future re-encoding doesn't +/// flap the band classification. +const EPS: f32 = 1e-4; + +fn close(a: f32, b: f32) -> bool { + (a - b).abs() < EPS +} + +/// Classify a `(f, c)` pair into one of the four documented provenance bands +/// (see `triple.rs` § "Truth-value provenance bands"). Returns `None` for +/// unrecognized bands — the test fails on `None`. +fn provenance_band(t: &Triple) -> Option<&'static str> { + if close(t.f, 1.0) && close(t.c, 1.0) { + Some("structural-declaration") + } else if close(t.f, 1.0) && close(t.c, 0.95) { + Some("structural-membership") + } else if close(t.f, 0.95) && close(t.c, 0.90) { + Some("decorator/authoritative") + } else if close(t.f, 0.85) && close(t.c, 0.75) { + Some("body-inferred") + } else { + None + } +} + +fn for_each_corpus(check: impl Fn(&str, &[Triple])) { + let s1 = parse_ndjson(SLICE_1).expect("slice-1 parses"); + let s2 = parse_ndjson(SLICE_2).expect("slice-2 parses"); + check("slice-1 (account_move)", &s1); + check("slice-2", &s2); +} + +#[test] +fn every_predicate_is_in_the_documented_set() { + for_each_corpus(|name, triples| { + let mut undocumented: Vec<&str> = triples + .iter() + .map(|t| t.p.as_str()) + .filter(|p| !DOCUMENTED_PREDICATES.contains(p)) + .collect(); + undocumented.sort_unstable(); + undocumented.dedup(); + assert!( + undocumented.is_empty(), + "{name}: undocumented predicates appeared in the corpus — the \ + schema docs must absorb them OR the harvester must stop emitting \ + them: {undocumented:?}" + ); + }); +} + +#[test] +fn every_triples_truth_falls_in_a_documented_provenance_band() { + for_each_corpus(|name, triples| { + let bad: Vec<&Triple> = triples + .iter() + .filter(|t| provenance_band(t).is_none()) + .collect(); + assert!( + bad.is_empty(), + "{name}: {} triple(s) carry a truth value outside the three \ + documented bands (structural 1.0/1.0, decorator 0.95/0.9, \ + body-inferred 0.85/0.75); first offender: {:?}", + bad.len(), + bad.first() + ); + }); +} + +#[test] +fn structural_bands_carry_only_structural_predicates() { + // The two structural bands are `rdf:type` (declaration, 1.0/1.0) and + // `has_function` (membership, 1.0/0.95). Nothing else should land there. + for_each_corpus(|name, triples| { + for t in triples { + match provenance_band(t) { + Some("structural-declaration") => assert_eq!( + t.p, "rdf:type", + "{name}: predicate `{}` carries the structural-declaration \ + band (1.0/1.0) but only `rdf:type` should", + t.p + ), + Some("structural-membership") => assert_eq!( + t.p, "has_function", + "{name}: predicate `{}` carries the structural-membership \ + band (1.0/0.95) but only `has_function` should", + t.p + ), + _ => {} + } + } + }); +} + +#[test] +fn inherits_from_is_never_a_self_loop() { + for_each_corpus(|name, triples| { + for t in triples.iter().filter(|t| t.p == "inherits_from") { + assert_ne!( + t.s, t.o, + "{name}: inherits_from self-loop on {} — the Odoo \ + extend-in-place idiom must be dropped at scan time", + t.s + ); + } + }); +} + +#[test] +fn reads_field_is_never_a_self_loop() { + // A method reading a field it itself emits is dropped — it's a recompute + // recursion artifact, not a real ordering constraint (mirrors the + // documented `recompute_dag` guard). + for_each_corpus(|name, triples| { + for t in triples.iter().filter(|t| t.p == "reads_field") { + assert_ne!( + t.s, t.o, + "{name}: reads_field self-loop ({} → {})", + t.s, t.o + ); + } + }); +} + +#[test] +fn inherits_from_base_is_well_formed_iri_even_when_extern_to_slice() { + // The slice corpora reference bases that resolve OUTSIDE the slice + // ("externs" in the compiler-AST view — `mail_activity_mixin`, + // `mail_thread`, …): account_move mixes them in but the slice carries + // only account_move + its direct relatives, not the mixin defs. That's + // AST-valid — the compiler's symbol table (OGAR `ClassView`) resolves + // them, not the slice. So the slice-aware invariant is **well-formedness** + // (every base is a properly-namespaced `odoo:` IRI), not + // declared-in-this-unit. + // + // The full-corpus integrity claim ("every inherits_from base is itself + // a declared ObjectType in the full corpus") lives in the upstream + // extractor's own tests, not here. + for_each_corpus(|name, triples| { + for t in triples.iter().filter(|t| t.p == "inherits_from") { + assert!( + t.o.starts_with("odoo:"), + "{name}: inherits_from base `{}` is not a properly-namespaced \ + `odoo:` IRI", + t.o + ); + assert!( + !t.o["odoo:".len()..].is_empty(), + "{name}: inherits_from base `{}` has empty local name", + t.o + ); + } + }); +} + +#[test] +fn target_object_is_raw_comodel_not_namespaced_iri() { + // `target` carries the raw comodel name from the Odoo source + // (`"account.move.line"`, sometimes a single word like `"website"`), + // NOT a namespaced IRI (`"odoo:account_move_line"`). This is the + // documented shape (ruff#18 sibling-triple convention) and the resolver + // in `RelationMap::from_corpus` depends on it. + // + // Single-word comodels DO occur in real Odoo (`fields.Many2one('website')` + // is shorthand for `website.website` — Odoo accepts both forms), so the + // invariant is "no `odoo:` prefix," not "must contain a dot." + for_each_corpus(|name, triples| { + for t in triples.iter().filter(|t| t.p == "target") { + assert!( + !t.o.starts_with("odoo:"), + "{name}: target object `{}` carries an odoo: prefix but \ + should be the raw comodel name", + t.o + ); + assert!( + !t.o.is_empty(), + "{name}: target object is empty for subject `{}`", + t.s + ); + } + }); +} + +#[test] +fn validation_kind_object_is_in_the_recognised_set() { + // Per `triple.rs` doc / lance-graph #526: an `@api.constrains` method is + // classified by AST pattern into one of five recognised kinds. + const KINDS: &[&str] = &["presence", "uniqueness", "range", "format", "lookup"]; + for_each_corpus(|name, triples| { + for t in triples.iter().filter(|t| t.p == "validation_kind") { + assert!( + KINDS.contains(&t.o.as_str()), + "{name}: validation_kind `{}` is not in the recognised set \ + (presence/uniqueness/range/format/lookup)", + t.o + ); + } + }); +} + +#[test] +fn raises_object_is_namespaced_exception() { + // `raises` objects are exception types in the `exc:` namespace + // (`exc:ValidationError`, `exc:UserError`, …). The shape is fixed. + for_each_corpus(|name, triples| { + for t in triples.iter().filter(|t| t.p == "raises") { + assert!( + t.o.starts_with("exc:"), + "{name}: raises object `{}` is not in the `exc:` namespace", + t.o + ); + } + }); +} + +/// One concrete data pin — the canonical convergence pin for the deep-read +/// enrichment. This is the load-bearing case the wishlist P0 closed on, and +/// it documents WHAT the corpus actually carries for any future session +/// reading this test as a fixture. +#[test] +fn account_move_compute_amount_carries_deep_cross_model_read_in_slice_2() { + let triples = parse_ndjson(SLICE_2).expect("slice-2 parses"); + let deep = triples.iter().any(|t| { + t.s == "odoo:account_move._compute_amount" + && t.p == "reads_field" + && t.o == "odoo:account_move_line.amount_residual" + }); + assert!( + deep, + "slice-2: the P0 deep-read enrichment must carry \ + `_compute_amount → account_move_line.amount_residual` (the canonical \ + cross-model recompute-ordering edge)" + ); +}