Skip to content

Commit 502a255

Browse files
committed
refactor(odoo): Core-first correction — structural facts home in typed Core, harvest is subordinate
Repurposes the P3 selection_value work after the operator caught a real architectural inversion: the wishlist's structural asks (target / inverse_name / inherits_from / selection_value) are CORE facts, not behavioural harvest, and belong in the typed `OdooEntity` Core (`lance-graph-ontology::odoo_blueprint`) — not bolted onto the flat SPO ndjson by `spo_enrich.py`. Source-verified: `OdooField.target` ALREADY exists in the Core, so the `target` harvest pass was re-deriving a fact the Core already held; `inherits_from` + `selection_value` were genuine Core gaps filled on the harvest side — the "Core gap → extend the Core deliberately, never hack the adapter" anti-pattern. # Why a side-table, not a struct field `OdooField` has 3 554 literal sites, `OdooEntity` 404, no constructor. Adding a field to the mega-structs breaks every literal. The doctrine-correct "extend the Core deliberately" for a literal sea that size is a TYPED SIDE-TABLE — still Core, still authoritative, beside the mega-structs instead of inside them. # What lands New `odoo_blueprint/structural.rs`: - `OdooInherits { model, bases }` — the _inherit/_inherits mixin chain (OdooEntityKind records the ORM base class, not the mixin list) - `OdooFieldSelection { model, field, values }` — Selection value domain (OdooFieldKind::Selection flags it; OdooField never stored the values) - Curated `account.move` consts, GROUNDED: INHERITS matches the #527 corpus (mail_activity_mixin + sequence_mixin); FIELD_SELECTIONS uses canonical standard Odoo state/move_type value sets. - `project_inherits_from` / `project_selection_value` — Core → SPO projection (direction of truth: Core out to SPO, never the reverse). - 5 tests incl. a consistency check against the #527 corpus. # Reframing (anti-self-fulfilling-drift) So a future session does NOT read the prior work as "add another spo_enrich predicate": - `spo_enrich.py` module-doc gains an ARCHITECTURE NOTE: the structural predicates' authoritative home is the typed Core; this file is the Extracted-leg BREADTH feeder for the ~322 ObjectTypes the curated Core hasn't reached; Curated Core wins on convergence. Behavioural predicates (deep reads_field, emitted_by, validation_kind) ARE genuine harvest and correctly stay. - `EPIPHANIES.md` prepends E-ODOO-CORE-FIRST-STRUCTURAL, which explicitly BOUNDS THE FRAMING of the four prior E-ODOO-SPO-* entries so the predicate-bolt-on cadence isn't read as the intended architecture. Names `virtually_overrides` as a ClassView/Core capability — NOT harvest predicate #6. # Not changed The selection_value AST reader in spo_enrich.py stays (it's the Extracted leg's mechanism for breadth) — now correctly subordinate to the Core. Behavioural harvest is untouched. # Tests cargo test -p lance-graph-ontology --lib structural : 5/5 python3 -m unittest tests.test_spo_enrich : 53/53 cargo test -p lance-graph --lib odoo_ontology : 13/13 (unchanged)
1 parent 6ded9e0 commit 502a255

4 files changed

Lines changed: 283 additions & 3 deletions

File tree

.claude/board/EPIPHANIES.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1+
## 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
2+
3+
**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.
4+
5+
**The correction.** The wishlist's structural asks — `target` / `inverse_name` (P1), `inherits_from` (P1b), `selection_value` (P3) — are **Core** facts (relations / composition / value-domain), per the Core-first transcode doctrine. Their authoritative home is the typed `OdooEntity` Core in `lance-graph-ontology::odoo_blueprint`, NOT the flat SPO ndjson. Source-verified 2026-06-18: **`OdooField.target: Option<&'static str>` already exists** in the Core — so `spo_enrich.py`'s `target` pass was *re-deriving a fact the Core already held*. `inherits_from` and `selection_value` were genuine **Core gaps** filled on the *harvest* side — the exact "a Core gap → extend the Core deliberately, never hack the adapter/harvest" anti-pattern.
6+
7+
**Why the harvest path looked right (and why it isn't).** `OdooField` has **3 554** literal sites, `OdooEntity` **404**, no constructor — adding a *field* to the mega-structs would break every one, so a predicate-on-the-ndjson felt like the only additive move. But the doctrine-correct extension for a literal sea that size is a **typed side-table**, which #530 now lands: `odoo_blueprint::structural::{OdooInherits, OdooFieldSelection}` + `project_inherits_from` / `project_selection_value` (Core → SPO, never the reverse). 5 tests; canonical `account.move` data grounded in the #527 corpus + standard Odoo value sets.
8+
9+
**The standing shape (so the next session does NOT re-drift).** Two legs converge on the `SpoStore` per `odoo-extraction-strategies-v1.md`: the **Curated leg** = typed Core (this module + the 66 L-doc entities), authoritative; the **Extracted leg** = `spo_enrich.py`, a *breadth* feeder for the ~322 ObjectTypes the Core hasn't reached, subordinate (Core wins on convergence). Structural facts live in the Core and project *out* to SPO; the harvest fills in where the Core is silent. **Behavioural** predicates (`reads_field` deep lifts, `emitted_by`, transitive `depends_on`, `validation_kind` body-AST classification) ARE genuine harvest and correctly stay in `spo_enrich.py`.
10+
11+
**`virtually_overrides` (the last wishlist item) is NOT a harvest predicate.** It is a ClassView/Core MRO-precedence capability. Doing it as `spo_enrich.py` predicate #6 would be the drift again. It belongs in the typed Core's class-resolution surface — the same conclusion the wishlist reached for a different reason.
12+
13+
**Cross-ref:** `spo_enrich.py` module-doc § "ARCHITECTURE NOTE"; `structural.rs` module doc; `core-first-transcode-doctrine.md`; `odoo-extraction-strategies-v1.md` (three legs). The four entries below are retained (append-only) with their framing now bounded by this one.
14+
115
## 2026-06-18 — E-ODOO-SPO-SELECTION-VALUE — spo_enrich gains selection_value (wishlist P3); corpus regen pending Odoo source
216

3-
**Status:** FINDING for the code + tests; CONJECTURE for the wire effect on the shipped corpus (regenerates when a session has `/home/user/odoo/addons`). `spo_enrich.py` adds a fifth enrichment pass via the same single AST walk: `fields.Selection([('draft','Draft'), …])` declarations emit one `(odoo:<model>.<field>, selection_value, "<key>")` triple per statically-resolvable enum key. 12 new tests (6 extraction + 3 scan-binding + 3 emission); total suite 41→53 green. Rust loader histogram arm gained `selection_value`.
17+
**Status:** FINDING for the code + tests; **framing bounded by E-ODOO-CORE-FIRST-STRUCTURAL above** — `selection_value` is a Core fact now homed in `odoo_blueprint::structural`; this harvest pass is the subordinate Extracted-leg breadth feeder, not the authoritative source. `spo_enrich.py` adds a fifth enrichment pass via the same single AST walk: `fields.Selection([('draft','Draft'), …])` declarations emit one `(odoo:<model>.<field>, selection_value, "<key>")` triple per statically-resolvable enum key. 12 new tests (6 extraction + 3 scan-binding + 3 emission); total suite 41→53 green. Rust loader histogram arm gained `selection_value`.
418

5-
**Shape.** `_extract_selection_values` pulls the first element of each 2-tuple from the Selection list (positional arg 0 OR `selection=` kwarg), preserving source order, de-duplicating. Dynamic selections — `selection='_compute_x'` (str method-ref), a bare Name constant, `related=` — are skipped (values not statically knowable). Truth `(0.95, 0.90)` — read straight from the decorator, authoritative. Scoped to corpus-declared ObjectTypes (the additive boundary); Selection fields bind to the same `model_names` as relational fields (`_name`, else `_inherit[0]`, per #525).
19+
**Shape.** `_extract_selection_values` pulls the first element of each 2-tuple from the Selection list (positional arg 0 OR `selection=` kwarg), preserving source order, de-duplicating. Dynamic selections — `selection='_compute_x'` (str method-ref), a bare Name constant, `related=` — are skipped (values not statically knowable). Truth `(0.95, 0.90)`. Scoped to corpus-declared ObjectTypes (the additive boundary); Selection fields bind to the same `model_names` as relational fields (`_name`, else `_inherit[0]`, per #525).
620

7-
**Consumer use.** Lets odoo-rs lower a Selection field to `DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']` — the wishlist P3 ask. Once a session with the Odoo source re-runs `python -m odoo_blueprint_extractor.spo_enrich`, the triples land and the predicate-histogram count can be locked. Remaining wishlist item after this: `virtually_overrides` (genuine ClassView MRO design, not a single-predicate emission).
21+
**Consumer use.** Lets odoo-rs lower a Selection field to `DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']` — the wishlist P3 ask. **Source of truth is `odoo_blueprint::structural::FIELD_SELECTIONS` (Core)**; this harvest gives breadth for uncurated models. `virtually_overrides` is a ClassView/Core concern (see correction above), NOT the next harvest predicate.
822

923
## 2026-06-18 — E-WITNESS-ARC-TWO-OBJECTS-1 — "witness arc" names TWO different objects; do NOT unify them under a `WitnessArcEvaluator` trait
1024

crates/lance-graph-ontology/src/odoo_blueprint/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ pub mod style_recipe;
9393
// build.rs → OUT_DIR → include!(). See op_emitter::emit_op_dispatch.
9494
pub mod op_emitter;
9595

96+
/// Structural Core extension — typed home for `inherits_from` +
97+
/// `selection_value` (the two structural gaps that do not fit the
98+
/// 3 554-literal `OdooField` / 404-literal `OdooEntity` mega-structs).
99+
/// Core is authoritative; the `spo_enrich.py` harvest is the subordinate
100+
/// Extracted-leg breadth feeder. See the module doc for the full rationale.
101+
pub mod structural;
102+
96103
// ─── Top-level entity ─────────────────────────────────────────────────────
97104

98105
/// Which ORM base class the entity inherits from.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright The Lance Authors
3+
4+
//! Structural Core extension — the typed home for `inherits_from` and
5+
//! `selection_value`.
6+
//!
7+
//! # Why this module exists (Core-first correction, 2026-06-18)
8+
//!
9+
//! Two structural facts about an Odoo model — its `_inherit`/`_inherits`
10+
//! **mixin chain** and a `fields.Selection` field's **allowed value set** —
11+
//! are *Core* properties (identity / composition / value-domain), not
12+
//! behavioural harvest. Per the Core-first transcode doctrine they belong in
13+
//! the deliberate typed Core ([`super::OdooEntity`] / [`super::OdooField`]),
14+
//! the single source of truth, **not** re-inferred onto the flat SPO ndjson
15+
//! by a separate AST pass.
16+
//!
17+
//! They could not be added as *fields* on the existing structs: `OdooField`
18+
//! has **3 554** literal sites and `OdooEntity` **404** across `l1..l15.rs`,
19+
//! none using a constructor — adding a field would break every one. For a
20+
//! literal sea that size the doctrine-correct "extend the Core deliberately"
21+
//! is a **typed side-table**, which is what this module is. It is still Core
22+
//! (`lance-graph-ontology::odoo_blueprint`), still authoritative, still
23+
//! `OdooConfidence::Curated`-grade; it simply lives beside the mega-structs
24+
//! instead of inside them.
25+
//!
26+
//! # Direction of truth: Core → SPO, never the reverse
27+
//!
28+
//! [`project_inherits_from`] and [`project_selection_value`] emit SPO triples
29+
//! **from** this typed Core. The `spo_enrich.py` AST harvest is the
30+
//! **Extracted leg** (per `odoo-extraction-strategies-v1.md`) — a *breadth*
31+
//! feeder for the ~322 ObjectTypes the curated Core has not yet reached. On
32+
//! convergence in the `SpoStore` the curated Core (this module,
33+
//! `0.95/0.90`) **wins** over the harvest's extracted confidence for any
34+
//! model it covers. The harvest never becomes the home for a structural
35+
//! fact; it fills in where the Core is silent.
36+
//!
37+
//! Behavioural predicates (`reads_field` deep lifts, `emitted_by`,
38+
//! transitive `depends_on`) are genuine harvest and stay in `spo_enrich.py`
39+
//! — they describe a method *body*, not the model's structure.
40+
41+
/// One model's `_inherit` / `_inherits` mixin chain — the composition gap
42+
/// the `OdooEntity` mega-struct does not carry (`OdooEntityKind` records the
43+
/// ORM *base class* `Model`/`Transient`/`Abstract`, not the mixin list).
44+
///
45+
/// `bases` are dotted Odoo model names (`"mail.activity.mixin"`); the SPO
46+
/// projection underscores them to match the corpus IRI convention.
47+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48+
pub struct OdooInherits {
49+
/// Owning model, dotted (`"account.move"`).
50+
pub model: &'static str,
51+
/// Mixin bases this model `_inherit`s, in declaration order.
52+
pub bases: &'static [&'static str],
53+
}
54+
55+
/// One `fields.Selection` field's statically-known value domain — the gap
56+
/// `OdooFieldKind::Selection` flags but `OdooField` does not store (only the
57+
/// `state` field's domain is reachable today, via `OdooStateMachine`).
58+
///
59+
/// `values` are the stored *keys* (the first element of each `('key',
60+
/// 'Label')` tuple), in declaration order.
61+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62+
pub struct OdooFieldSelection {
63+
/// Owning model, dotted (`"account.move"`).
64+
pub model: &'static str,
65+
/// Selection field name (`"state"`, `"move_type"`).
66+
pub field: &'static str,
67+
/// Allowed value keys, in source order.
68+
pub values: &'static [&'static str],
69+
}
70+
71+
// ─── Curated data — canonical `account.move` (grounded, not invented) ──────
72+
//
73+
// `INHERITS` is grounded in the #527-regenerated corpus
74+
// (`grep '"s":"odoo:account_move","p":"inherits_from"'`): the corpus captured
75+
// `mail_activity_mixin` + `sequence_mixin` (it drops bases that are not
76+
// themselves corpus ObjectTypes — `mail.thread` / `portal.mixin` fell outside
77+
// that boundary). The curated Core records the corpus-confirmed pair; a future
78+
// L-doc curation pass may widen it to the full real chain (the Core is allowed
79+
// to exceed the harvest's ObjectType-boundary, since it is authoritative).
80+
//
81+
// `FIELD_SELECTIONS` uses the canonical, stable Odoo `account.move` value sets
82+
// (`state`, `move_type`) — standard across Odoo versions, verifiable against
83+
// `addons/account/models/account_move.py`.
84+
85+
/// Curated inherit-chains. APPEND-ONLY as L-doc curation reaches more models.
86+
pub const INHERITS: &[OdooInherits] = &[
87+
OdooInherits {
88+
model: "account.move",
89+
bases: &["mail.activity.mixin", "sequence.mixin"],
90+
},
91+
OdooInherits {
92+
model: "account.move.line",
93+
bases: &["analytic.mixin"],
94+
},
95+
];
96+
97+
/// Curated Selection value domains. APPEND-ONLY.
98+
pub const FIELD_SELECTIONS: &[OdooFieldSelection] = &[
99+
OdooFieldSelection {
100+
model: "account.move",
101+
field: "state",
102+
values: &["draft", "posted", "cancel"],
103+
},
104+
OdooFieldSelection {
105+
model: "account.move",
106+
field: "move_type",
107+
values: &[
108+
"entry",
109+
"out_invoice",
110+
"out_refund",
111+
"in_invoice",
112+
"in_refund",
113+
"out_receipt",
114+
"in_receipt",
115+
],
116+
},
117+
];
118+
119+
// ─── Core → SPO projection ─────────────────────────────────────────────────
120+
121+
/// `account.move` → `account_move` (corpus IRI local-part convention).
122+
fn underscore(dotted: &str) -> String {
123+
dotted.replace('.', "_")
124+
}
125+
126+
/// One projected SPO triple: `(subject, predicate, object)`. Truth values are
127+
/// fixed at the curated grade `(0.95, 0.90)` by the projection (declared,
128+
/// authoritative), so callers serialising to ndjson append `,"f":0.95,"c":0.9`.
129+
pub type SpoTriple = (String, &'static str, String);
130+
131+
/// Project the curated inherit-chains into `inherits_from` SPO triples,
132+
/// `(odoo:<model>, inherits_from, odoo:<base>)`. Both endpoints underscored.
133+
///
134+
/// This is the authoritative source for `inherits_from` on every model in
135+
/// [`INHERITS`]; the harvest only supplies models absent here.
136+
#[must_use]
137+
pub fn project_inherits_from(table: &[OdooInherits]) -> Vec<SpoTriple> {
138+
let mut out = Vec::new();
139+
for row in table {
140+
let child = format!("odoo:{}", underscore(row.model));
141+
for base in row.bases {
142+
out.push((child.clone(), "inherits_from", format!("odoo:{}", underscore(base))));
143+
}
144+
}
145+
out
146+
}
147+
148+
/// Project the curated Selection domains into `selection_value` SPO triples,
149+
/// `(odoo:<model>.<field>, selection_value, "<key>")`. One per value, source
150+
/// order preserved.
151+
#[must_use]
152+
pub fn project_selection_value(table: &[OdooFieldSelection]) -> Vec<SpoTriple> {
153+
let mut out = Vec::new();
154+
for row in table {
155+
let subj = format!("odoo:{}.{}", underscore(row.model), row.field);
156+
for v in row.values {
157+
out.push((subj.clone(), "selection_value", (*v).to_string()));
158+
}
159+
}
160+
out
161+
}
162+
163+
#[cfg(test)]
164+
mod tests {
165+
use super::*;
166+
167+
#[test]
168+
fn inherits_projection_underscores_both_endpoints() {
169+
let triples = project_inherits_from(INHERITS);
170+
assert!(triples.contains(&(
171+
"odoo:account_move".to_string(),
172+
"inherits_from",
173+
"odoo:mail_activity_mixin".to_string(),
174+
)));
175+
assert!(triples.contains(&(
176+
"odoo:account_move".to_string(),
177+
"inherits_from",
178+
"odoo:sequence_mixin".to_string(),
179+
)));
180+
// account.move.line → account_move_line, analytic.mixin → analytic_mixin
181+
assert!(triples.contains(&(
182+
"odoo:account_move_line".to_string(),
183+
"inherits_from",
184+
"odoo:analytic_mixin".to_string(),
185+
)));
186+
}
187+
188+
#[test]
189+
fn inherits_projection_matches_527_corpus() {
190+
// The two account.move bases the projection emits are exactly the pair
191+
// the #527 corpus regen captured — the typed Core is consistent with
192+
// (not contradicting) the harvest, and is the authoritative home.
193+
let triples = project_inherits_from(INHERITS);
194+
let am_bases: Vec<&str> = triples
195+
.iter()
196+
.filter(|(s, _, _)| s == "odoo:account_move")
197+
.map(|(_, _, o)| o.as_str())
198+
.collect();
199+
assert_eq!(am_bases, vec!["odoo:mail_activity_mixin", "odoo:sequence_mixin"]);
200+
}
201+
202+
#[test]
203+
fn selection_projection_one_triple_per_value_in_order() {
204+
let triples = project_selection_value(FIELD_SELECTIONS);
205+
let state_vals: Vec<&str> = triples
206+
.iter()
207+
.filter(|(s, _, _)| s == "odoo:account_move.state")
208+
.map(|(_, _, o)| o.as_str())
209+
.collect();
210+
assert_eq!(state_vals, vec!["draft", "posted", "cancel"]);
211+
212+
let move_type_vals: Vec<&str> = triples
213+
.iter()
214+
.filter(|(s, _, _)| s == "odoo:account_move.move_type")
215+
.map(|(_, _, o)| o.as_str())
216+
.collect();
217+
assert_eq!(move_type_vals[0], "entry");
218+
assert_eq!(move_type_vals.len(), 7);
219+
}
220+
221+
#[test]
222+
fn selection_subject_is_field_iri_not_model() {
223+
let triples = project_selection_value(FIELD_SELECTIONS);
224+
// selection_value keys on the FIELD, never the model.
225+
assert!(triples.iter().all(|(s, _, _)| s.contains('.')));
226+
}
227+
228+
#[test]
229+
fn registries_are_nonempty_and_curated() {
230+
assert!(!INHERITS.is_empty());
231+
assert!(!FIELD_SELECTIONS.is_empty());
232+
}
233+
}

tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,32 @@
8383
`od_ontology::RecomputeDag` (which today sees only the relation read,
8484
leaving the line→move dependency structurally invisible).
8585
86+
# ARCHITECTURE NOTE — this is the breadth feeder, NOT the home (2026-06-18)
87+
88+
The *structural* predicates here — `target` / `inverse_name` / `inherits_from`
89+
/ `selection_value` — are **Core** facts (relations / composition / value
90+
domain). Their AUTHORITATIVE home is the typed `OdooEntity` Core in
91+
`lance-graph-ontology::odoo_blueprint` (`OdooField.target` already exists;
92+
`inherits_from` + `selection_value` live in the
93+
`odoo_blueprint::structural` side-table). This module is the **Extracted
94+
leg** (per `.claude/knowledge/odoo-extraction-strategies-v1.md`): a *breadth*
95+
feeder that AST-walks the full Odoo source for the ~322 ObjectTypes the
96+
curated Core has not yet reached. On convergence in the `SpoStore` the
97+
curated Core (`OdooConfidence::Curated`) WINS over this leg's extracted
98+
confidence for any model it covers — the harvest never becomes the home for
99+
a structural fact, it fills in where the Core is silent.
100+
101+
Reading this file as "where structural predicates live" is the
102+
self-fulfilling drift the 2026-06-18 Core-first correction reversed
103+
(see `EPIPHANIES.md` E-ODOO-CORE-FIRST-STRUCTURAL). Do NOT add
104+
`virtually_overrides` here — it is a ClassView/Core MRO capability, not a
105+
harvest predicate.
106+
107+
The *behavioural* predicates (`reads_field` deep lifts, `emitted_by`,
108+
transitive `depends_on`, `raises`, `validation_kind` body classification)
109+
ARE genuine harvest — they describe a method *body*, not the model's
110+
structure — and correctly live here.
111+
86112
# Why a separate enrichment pass (not the ORM extractor)
87113
88114
The ORM extractor (`parsers/`, `emitters/`) emits typed Rust `OdooEntity`

0 commit comments

Comments
 (0)