From a8d5ebd1273a5672dd5276bbb1fa835014577948 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 19:29:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(od-ontology):=20corpus=5Fto=5Factions=20?= =?UTF-8?q?=E2=80=94=20the=20first=20behavioral-arm=20lowering=20(W3.3=20p?= =?UTF-8?q?art=20b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo's reactive lifecycle → ogar_vocab::ActionDef, the sibling of the structural schema_to_classes. This is the lowering the spellbook + W3 scope (#6/#7) point at: behavior is ActionDef (the value a classid resolves to), NOT DDL. Consumes ogar-vocab (already pinned) — no fork, no surreal_ast, no new dep. Two arms, built from the facts the corpus actually carries (verified the open checks first, per the spell — ActionDef is un-feature-gated + consumable; the corpus has depends_on/reads_field/has_function/raises): _compute_* + reads_field → ActionDef{ kausal: Depends{paths}, temporal: OnCommit, subject: Trigger } guard method (raises) → ActionDef{ kausal: LifecycleTrigger{"before_save"}, guard_failure_policy: Reject } `raises` is the robust guard signal (catches _check_* AND _constrains_* regardless of naming); it wins over the _compute_ classification. Onchange/ Inverse/Search/Other are not the behavioral arm — not lowered. Deferred (a producer-side fact gap, NOT a Core gap): the action_* state crossing (→ EnterEffect) needs a writes_state/transitions_to predicate the corpus doesn't carry yet. Documented in the module; an odoo-spo producer ask. 6 tests incl. slice_2_corpus_yields_both_arms (real corpus: >50 Depends + guards, every guard Reject, every Depends has paths). clippy 0 (correctly recognizes ActionDef's #[non_exhaustive] → Default+field-set is the only ctor), fmt-clean. Co-Authored-By: Claude Opus 4.8 --- crates/od-ontology/src/lib.rs | 7 + crates/od-ontology/src/ogar_actions.rs | 267 +++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 crates/od-ontology/src/ogar_actions.rs diff --git a/crates/od-ontology/src/lib.rs b/crates/od-ontology/src/lib.rs index f308ac6..a9b9b5a 100644 --- a/crates/od-ontology/src/lib.rs +++ b/crates/od-ontology/src/lib.rs @@ -50,6 +50,8 @@ mod emit; mod inheritance; #[cfg(feature = "ogar-emit")] +mod ogar_actions; +#[cfg(feature = "ogar-emit")] mod ogar_bridge; mod recompute_dag; mod relations; @@ -62,6 +64,11 @@ pub use ogar_bridge::{ schema_to_classes, ODOO_APP_PREFIX, }; +/// Behavioral-arm lowering — Odoo's reactive lifecycle → `ogar_vocab::ActionDef` +/// (the sibling of [`schema_to_classes`]). See `specs/W3-BEHAVIORAL-ARM-SCOPE.md`. +#[cfg(feature = "ogar-emit")] +pub use ogar_actions::corpus_to_actions; + /// Re-export the canonical OGAR codebook constants (e.g. /// `class_ids::BILLABLE_WORK_ENTRY`) so consumers can symbol-bind to the /// shared ids rather than copy hex literals. diff --git a/crates/od-ontology/src/ogar_actions.rs b/crates/od-ontology/src/ogar_actions.rs new file mode 100644 index 0000000..2a50329 --- /dev/null +++ b/crates/od-ontology/src/ogar_actions.rs @@ -0,0 +1,267 @@ +//! **W3.3 behavioral-arm lowering** — Odoo's reactive lifecycle → the OGAR +//! behavioral arm (`ogar_vocab::ActionDef` + `KausalSpec`), the sibling of the +//! structural [`schema_to_classes`](crate::schema_to_classes). +//! +//! This is the lowering the SurrealQL-AST-trap spellbook +//! (`specs/SURREAL-AST-TRAP.md`) and the W3 scope +//! (`specs/W3-BEHAVIORAL-ARM-SCOPE.md`) point at: Odoo's behavior is *not* DDL +//! (`DEFINE FUNCTION`/`DEFINE EVENT`); it is `ActionDef`, the value a classid +//! resolves to. The corpus carries the facts; this maps them onto the Core +//! vocabulary that `od-ontology` already pins (`ogar-vocab`, no new dep). +//! +//! # Two arms built (the facts the corpus carries) +//! +//! | Odoo source | corpus fact | → `ActionDef` | +//! |---|---|---| +//! | `_compute_*` + `@api.depends` | `(method, reads_field, field)` | `kausal: Depends { paths }`, `temporal: OnCommit`, `subject: Trigger` | +//! | `@api.constrains` guard | `(method, raises, exc:…)` | `kausal: LifecycleTrigger{"before_save"}`, `guard_failure_policy: Reject` | +//! +//! # One arm deferred (a producer-side gap, NOT a Core gap) +//! +//! The `action_*` **state crossing** (`action_post` sets `state := "posted"` +//! → [`ogar_vocab::EnterEffect`]) needs an explicit transition fact the corpus +//! does **not** carry today (there is no `writes_state` / `transitions_to` +//! predicate — only `emitted_by`/`reads_field`/`depends_on`). That is a +//! `lance-graph` odoo-spo producer ask, filed in `specs/UPSTREAM_WISHLIST.md`, +//! not something to synthesize here. + +use std::collections::{BTreeMap, BTreeSet}; + +use ogar_vocab::{ActionDef, ActionSubject, GuardFailurePolicy, KausalSpec, TemporalSpec}; + +use crate::recompute_dag::MethodKind; +use crate::triple::{member_of, model_of, strip_ns, Triple}; + +/// Lower the SPO corpus's behavioral facts onto canonical +/// [`ogar_vocab::ActionDef`]s — one per `_compute_*` method (recompute arm) +/// and one per guard method that `raises` (constrains arm). Deterministic +/// order (sorted by method identity). +/// +/// Methods that are neither (`_onchange_*` cooperative UI loops, `_inverse_*` +/// write-backs, `_search_*`, plain helpers) are intentionally not lowered — +/// they are not the reactive/guard behavioral arm. The `action_*` state- +/// crossing arm is deferred (see the module docs — a producer-side fact gap). +#[must_use] +pub fn corpus_to_actions(triples: &[Triple]) -> Vec { + let mut methods: BTreeSet<&str> = BTreeSet::new(); + let mut reads: BTreeMap<&str, BTreeSet> = BTreeMap::new(); + let mut raises: BTreeSet<&str> = BTreeSet::new(); + + for t in triples { + match t.p.as_str() { + "has_function" => { + methods.insert(strip_ns(&t.o)); + } + "reads_field" => { + reads + .entry(strip_ns(&t.s)) + .or_default() + .insert(strip_ns(&t.o).to_string()); + } + "raises" => { + raises.insert(strip_ns(&t.s)); + } + _ => {} + } + } + + let mut out: Vec = Vec::new(); + for &method in &methods { + let model = model_of(method); + let member = member_of(method).unwrap_or(method); + + // `ActionDef` is `#[non_exhaustive]` — build via Default + field set. + let mut def = ActionDef::default(); + def.identity = format!("odoo/{model}::action_def::{member}"); + def.predicate = member.to_string(); + def.object_class = model.to_string(); + + if raises.contains(method) { + // Guard arm: an `@api.constrains` validator that raises. Fires as a + // lifecycle event; a raise is a hard reject (Pending → Failed). + def.kausal = Some(KausalSpec::LifecycleTrigger { + event: "before_save".to_string(), + }); + def.guard_failure_policy = Some(GuardFailurePolicy::Reject); + def.decorators = vec!["api.constrains".to_string()]; + out.push(def); + } else if MethodKind::classify(method) == MethodKind::Compute { + // Recompute arm: `@api.depends` reactive compute. The dependency + // paths are the method's `reads_field` objects (the lifted + // `@api.depends`, per lance-graph #523). Odoo materializes stored + // computes on write/commit → OnCommit, reactively → Trigger. + let paths: Vec = reads + .get(method) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + def.kausal = Some(KausalSpec::Depends { paths }); + def.default_temporal = TemporalSpec::OnCommit; + def.default_subject = ActionSubject::Trigger; + def.decorators = vec!["api.depends".to_string()]; + out.push(def); + } + // else: Onchange / Inverse / Search / Other — not the behavioral arm. + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(s: &str, p: &str, o: &str) -> Triple { + Triple { + s: s.into(), + p: p.into(), + o: o.into(), + f: 0.9, + c: 0.9, + } + } + + #[test] + fn compute_method_lowers_to_depends_action() { + let triples = vec![ + t( + "odoo:account_move", + "has_function", + "odoo:account_move._compute_amount", + ), + t( + "odoo:account_move._compute_amount", + "reads_field", + "odoo:account_move.line_ids", + ), + t( + "odoo:account_move._compute_amount", + "reads_field", + "odoo:account_move_line.amount_residual", + ), + ]; + let actions = corpus_to_actions(&triples); + assert_eq!(actions.len(), 1); + let a = &actions[0]; + assert_eq!(a.predicate, "_compute_amount"); + assert_eq!(a.object_class, "account_move"); + assert_eq!(a.default_temporal, TemporalSpec::OnCommit); + assert_eq!(a.default_subject, ActionSubject::Trigger); + match &a.kausal { + Some(KausalSpec::Depends { paths }) => { + assert!(paths.contains(&"account_move.line_ids".to_string())); + assert!(paths.contains(&"account_move_line.amount_residual".to_string())); + } + other => panic!("expected Depends, got {other:?}"), + } + assert!(a.guard_failure_policy.is_none()); + } + + #[test] + fn raising_guard_method_lowers_to_lifecycle_reject() { + let triples = vec![ + t( + "odoo:account_move", + "has_function", + "odoo:account_move._check_balanced", + ), + t( + "odoo:account_move._check_balanced", + "raises", + "exc:ValidationError", + ), + ]; + let actions = corpus_to_actions(&triples); + assert_eq!(actions.len(), 1); + let a = &actions[0]; + assert_eq!(a.predicate, "_check_balanced"); + assert_eq!( + a.kausal, + Some(KausalSpec::LifecycleTrigger { + event: "before_save".to_string() + }) + ); + assert_eq!(a.guard_failure_policy, Some(GuardFailurePolicy::Reject)); + assert_eq!(a.decorators, vec!["api.constrains".to_string()]); + } + + #[test] + fn raises_wins_over_compute_classification() { + // A `_compute_*`-named method that also raises is a guard, not a + // recompute — the `raises` fact is the stronger signal. + let triples = vec![ + t("odoo:m", "has_function", "odoo:m._compute_guarded"), + t("odoo:m._compute_guarded", "raises", "exc:ValidationError"), + t("odoo:m._compute_guarded", "reads_field", "odoo:m.x"), + ]; + let actions = corpus_to_actions(&triples); + assert_eq!(actions.len(), 1); + assert!(matches!( + actions[0].kausal, + Some(KausalSpec::LifecycleTrigger { .. }) + )); + } + + #[test] + fn onchange_inverse_search_other_are_not_lowered() { + let triples = vec![ + t("odoo:m", "has_function", "odoo:m._onchange_partner"), + t("odoo:m", "has_function", "odoo:m._inverse_total"), + t("odoo:m", "has_function", "odoo:m._search_name"), + t("odoo:m", "has_function", "odoo:m.action_post"), + ]; + // none raise, none are _compute_* → no behavioral arm + assert!(corpus_to_actions(&triples).is_empty()); + } + + #[test] + fn identity_is_stable_and_namespaced() { + let triples = vec![t( + "odoo:account_move", + "has_function", + "odoo:account_move._compute_amount", + )]; + let actions = corpus_to_actions(&triples); + assert_eq!( + actions[0].identity, + "odoo/account_move::action_def::_compute_amount" + ); + } + + #[test] + fn slice_2_corpus_yields_both_arms() { + let ndjson = include_str!("../../../data/slice_2.spo.ndjson"); + let triples = crate::triple::parse_ndjson(ndjson).expect("slice 2 parses"); + let actions = corpus_to_actions(&triples); + let depends = actions + .iter() + .filter(|a| matches!(a.kausal, Some(KausalSpec::Depends { .. }))) + .count(); + let guards = actions + .iter() + .filter(|a| matches!(a.kausal, Some(KausalSpec::LifecycleTrigger { .. }))) + .count(); + eprintln!( + "slice 2 actions: {} total, {depends} depends, {guards} guards", + actions.len() + ); + assert!( + depends > 50, + "expected many compute→Depends actions, saw {depends}" + ); + assert!( + guards > 0, + "expected constrains→guard actions, saw {guards}" + ); + // every guard carries the Reject policy; every Depends carries paths. + for a in &actions { + match &a.kausal { + Some(KausalSpec::Depends { .. }) => { + assert_eq!(a.guard_failure_policy, None); + } + Some(KausalSpec::LifecycleTrigger { .. }) => { + assert_eq!(a.guard_failure_policy, Some(GuardFailurePolicy::Reject)); + } + other => panic!("unexpected kausal in v1 lowering: {other:?}"), + } + } + } +}