From 6ded9e0e57a3a5f29afba00bb4d4c3d5142da3b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 06:53:09 +0000 Subject: [PATCH 1/2] feat(odoo-spo): selection_value (wishlist P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fifth enrichment pass to spo_enrich.py: `fields.Selection` declarations with a statically-resolvable list of 2-tuples emit one `(odoo:., selection_value, "")` triple per enum key. # Shape `_extract_selection_values(call)` pulls the first element of each 2-tuple from the Selection list — positional arg 0 OR `selection=` kwarg — preserving source order, de-duplicating (first occurrence wins). Skipped (values not statically knowable): - `selection='_compute_states'` (compute-method ref, a str) - `selection=STATE_CONSTANT` (bare Name) - `related=...` / any non-list/tuple selection arg - individual entries that aren't 2-tuples Truth `(0.95, 0.90)` — read straight from the field decorator, authoritative. Scoped to corpus-declared ObjectTypes (the additive boundary, same as P1); Selection fields bind to the same `model_names` as relational fields (`_name`, else `_inherit[0]`, per #525's decision). # Consumer use Lets odoo-rs lower a Selection field to `DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']` — the UPSTREAM_WISHLIST P3 ask. The five-pass enrichment is now: P1 (target/inverse_name) + P0 (deep reads_field) + P1b (inherits_from) + P2 (validation_kind) + P3 (selection_value). # Corpus regen pending The shipped corpus does not yet carry selection_value triples — regenerating requires running the script against a live Odoo source tree (`/home/user/odoo/addons`), not present on this host. The Rust loader's predicate-histogram match arm gained `selection_value` so a future regenerated corpus drops into the harness without code changes. `parses_all_triples` count assertion stays at 24 579; re-locks the moment a session with the source re-runs enrichment. # Files spo_enrich.py: +`_extract_selection_values` helper, +`SELECTION_VALUE_TRUTH`, +`selections` param threaded through `_scan_file` / `build_all_facts` / `enrich`, +Selection branch in the field loop, +P3 emission loop, +CLI status field. test_spo_enrich.py: +12 tests (6 extraction edge cases + 3 scan-binding + 3 emission). odoo_ontology.rs: +doc table row, +histogram match arm for `selection_value`. EPIPHANIES.md: prepended E-ODOO-SPO-SELECTION-VALUE. # Tests python3 -m unittest tests.test_spo_enrich : 53/53 OK (was 41) cargo test -p lance-graph --lib odoo_ontology : 13/13 OK --- .claude/board/EPIPHANIES.md | 8 ++ .../src/graph/spo/odoo_ontology.rs | 2 + .../odoo_blueprint_extractor/spo_enrich.py | 107 +++++++++++++- .../tests/test_spo_enrich.py | 132 ++++++++++++++++++ 4 files changed, 243 insertions(+), 6 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 99fc1274..ef0d4f1b 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,11 @@ +## 2026-06-18 — E-ODOO-SPO-SELECTION-VALUE — spo_enrich gains selection_value (wishlist P3); corpus regen pending Odoo source + +**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:., selection_value, "")` 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`. + +**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). + +**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). + ## 2026-06-18 — E-WITNESS-ARC-TWO-OBJECTS-1 — "witness arc" names TWO different objects; do NOT unify them under a `WitnessArcEvaluator` trait **Status:** FINDING (5+3 council, unanimous: convergence-architect DROP, iron-rule-savant REJECT-trait, dto-soa-savant FITS-COLUMN-as-free-fn, dilution-collapse-sentinel KEEP-SEPARATE, truth-architect PROVEN-math, brutally-honest-tester Option-B-LAND, baton-handoff-auditor CATCH-CRITICAL, integration-lead DEFER). B2 resolved as documentation, not code. diff --git a/crates/lance-graph/src/graph/spo/odoo_ontology.rs b/crates/lance-graph/src/graph/spo/odoo_ontology.rs index a65dea51..73d7bce5 100644 --- a/crates/lance-graph/src/graph/spo/odoo_ontology.rs +++ b/crates/lance-graph/src/graph/spo/odoo_ontology.rs @@ -26,6 +26,7 @@ //! | `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) | //! //! ## FK-target + deep-read enrichment (`spo_enrich`) //! @@ -183,6 +184,7 @@ mod tests { "inverse_name" => "inverse_name", "inherits_from" => "inherits_from", "validation_kind" => "validation_kind", + "selection_value" => "selection_value", _ => "other", }) .or_default() += 1; diff --git a/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py b/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py index 404b7564..313bb55c 100644 --- a/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py +++ b/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py @@ -1,5 +1,6 @@ """SPO corpus enrichment — FK target/inverse_name (P1) + deep reads_field (P0) -+ inherits_from (P1b, ruff#19) + validation_kind (P2, ruff#21). ++ inherits_from (P1b, ruff#19) + validation_kind (P2, ruff#21) ++ selection_value (P3). Stdlib-only Python 3. Reads the Odoo ORM source (the same addons tree the ORM extractor parses) to build a relation-target map, then enriches an @@ -60,6 +61,22 @@ extension classes bind to `_inherit[0]` (mirrors `_scan_file`'s field binding decision per #525 codex review). + * **P3 — `selection_value`** (UPSTREAM_WISHLIST P3): for every + `fields.Selection([('draft','Draft'), ('posted','Posted'), …])` + declaration with a *statically resolvable* list of 2-tuples, emit one + triple per value KEY (the first tuple element), keyed by the field IRI:: + + (odoo:account_move.state, selection_value, "draft") + (odoo:account_move.state, selection_value, "posted") + (odoo:account_move.state, selection_value, "cancel") + + Enables the consumer to lower a Selection field to + `DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']`. + Dynamic selections (`selection='_compute_states'`, a bare Name constant, + or `related=`) are skipped — their values aren't statically knowable. + Source order is preserved. Selection fields bind to the same + `model_names` as relational fields (`_name`, else `_inherit[0]`). + This is *in addition* to the existing shallow relation read `(_compute_amount, reads_field, odoo:account_move.line_ids)`. The deep read makes the cross-model recompute-ordering edge visible to @@ -110,12 +127,49 @@ # validation_kind is an AST-pattern classification of a method body # (heuristic, not authoritative). VALIDATION_KIND_TRUTH = (0.85, 0.75) +# selection_value is a statically-resolved enum key from a Selection +# declaration (authoritative — read straight from the field decorator). +SELECTION_VALUE_TRUTH = (0.95, 0.90) # Recognised `validation_kind` strings (matches ruff#21's Rails set where # semantics overlap; Odoo-specific patterns like `lookup` extend it). _VALIDATION_KINDS = ("presence", "uniqueness", "range", "format", "lookup") +def _extract_selection_values(call: ast.Call) -> List[str]: + """Pull the value KEYS from a `fields.Selection([('k','Label'), …])` + declaration. The selection list is the first positional arg OR the + `selection=` kwarg; each entry is a 2-tuple whose first element is the + stored key. + + Returns the keys in source order, de-duplicated (first occurrence wins). + Returns `[]` for dynamic selections — a bare Name (constant reference), + a string (compute-method name), `related=`, or any entry whose first + element isn't a string constant. Those values aren't statically known. + """ + sel: Optional[ast.expr] = None + for kw in call.keywords: + if kw.arg == "selection": + sel = kw.value + break + if sel is None and call.args: + sel = call.args[0] + if not isinstance(sel, (ast.List, ast.Tuple)): + # `selection='_compute_x'` (str), a Name constant, or related= → skip. + return [] + + out: List[str] = [] + seen: Set[str] = set() + for elt in sel.elts: + if not isinstance(elt, (ast.Tuple, ast.List)) or len(elt.elts) < 1: + continue + key = _const_str(elt.elts[0]) + if key is not None and key not in seen: + seen.add(key) + out.append(key) + return out + + def _is_uniqueness_call(node: ast.expr) -> bool: """True iff `node` is a `.search_count(...)` call — the LHS of the canonical Odoo uniqueness pattern. Used to suppress `range` for @@ -222,6 +276,7 @@ def _scan_file( relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]], inherits: Optional[Dict[str, Set[str]]] = None, constrains: Optional[Dict[Tuple[str, str], Set[str]]] = None, + selections: Optional[Dict[Tuple[str, str], List[str]]] = None, ) -> None: """Parse one .py file; record every relational field on every named model. Optionally also record `inherits_from` edges and `@api.constrains` @@ -262,6 +317,7 @@ def _scan_file( inherits_models: List[str] = [] # _inherits dict keys (delegation) local_fields: Dict[str, Tuple[str, Optional[str]]] = {} local_constrains: List[Tuple[str, ast.FunctionDef]] = [] + local_selections: Dict[str, List[str]] = {} for stmt in node.body: # Method definitions — gather @api.constrains methods for P2. @@ -311,6 +367,12 @@ def _scan_file( ): continue field_type = stmt.value.func.attr + # Selection field — extract statically-known value keys (P3). + if field_type == "Selection" and selections is not None: + vals = _extract_selection_values(stmt.value) + if vals: + local_selections[target_name] = vals + continue if field_type not in RELATIONAL_KINDS: continue @@ -359,6 +421,11 @@ def _scan_file( # Last write wins across _inherit reopenings of the same model; # comodel for a given (model, field) is stable in practice. relmap[(mu, field_name)] = (comodel, inverse) + if selections is not None: + for field_name, vals in local_selections.items(): + # Last write wins (a later reopening that redeclares the + # Selection with a fuller value list supersedes). + selections[(mu, field_name)] = vals # ── P1b: inherits_from edges ───────────────────────────────────── # Only when the class declares a NEW model (_name is set). An @@ -393,21 +460,24 @@ def build_all_facts( Dict[Tuple[str, str], Tuple[str, Optional[str]]], Dict[str, Set[str]], Dict[Tuple[str, str], Set[str]], + Dict[Tuple[str, str], List[str]], ]: - """Single-pass scan that populates (relmap, inherits, constrains). + """Single-pass scan that populates (relmap, inherits, constrains, selections). Returns: relmap — (model_us, field) → (comodel_dotted, inverse_or_None) inherits — model_us → set(base_us) from `_inherit` + `_inherits` constrains — (model_us, method) → set(kind) from `@api.constrains` + selections — (model_us, field) → [value_key, …] from `fields.Selection` """ relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]] = {} inherits: Dict[str, Set[str]] = {} constrains: Dict[Tuple[str, str], Set[str]] = {} + selections: Dict[Tuple[str, str], List[str]] = {} pattern = os.path.join(addons_root, "**", "*.py") for path in glob.iglob(pattern, recursive=True): - _scan_file(path, relmap, inherits, constrains) - return relmap, inherits, constrains + _scan_file(path, relmap, inherits, constrains, selections) + return relmap, inherits, constrains, selections def build_relation_map(addons_root: str) -> Dict[Tuple[str, str], Tuple[str, Optional[str]]]: @@ -468,6 +538,7 @@ def enrich( relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]], inherits: Optional[Dict[str, Set[str]]] = None, constrains: Optional[Dict[Tuple[str, str], Set[str]]] = None, + selections: Optional[Dict[Tuple[str, str], List[str]]] = None, ) -> Tuple[List[str], dict]: """Compute the additive enrichment lines + a stats dict. @@ -544,6 +615,8 @@ def enrich( "inherits_skip_unknown_base": 0, "validation_kind": 0, "validation_skip_unknown_method": 0, + "selection_value": 0, + "selection_skip_unknown_model": 0, } # ── P1: target / inverse_name ───────────────────────────────────────── @@ -661,6 +734,26 @@ def enrich( existing_spo.add(spo) stats["validation_kind"] += 1 + # ── P3: selection_value ─────────────────────────────────────────────── + # One triple per statically-known enum key on a Selection field. Scoped to + # corpus-declared ObjectTypes (the additive boundary): never invent a + # selection on an unknown model. Value order preserved (source order). + if selections: + for (model_us, field_name), vals in sorted(selections.items()): + if model_us not in object_type_models: + stats["selection_skip_unknown_model"] += 1 + continue + field_iri = f"odoo:{model_us}.{field_name}" + for val in vals: + spo = (field_iri, "selection_value", val) + if spo in existing_spo: + continue + new_lines.append( + triple_line(field_iri, "selection_value", val, *SELECTION_VALUE_TRUTH) + ) + existing_spo.add(spo) + stats["selection_value"] += 1 + new_lines.sort() return new_lines, stats @@ -668,12 +761,13 @@ def enrich( def run(corpus_path: str, addons_root: str, out_path: str) -> dict: """Enrich `corpus_path` using `addons_root`, write to `out_path`. Returns stats.""" triples = load_corpus(corpus_path) - relmap, inherits, constrains = build_all_facts(addons_root) - new_lines, stats = enrich(triples, relmap, inherits, constrains) + relmap, inherits, constrains, selections = build_all_facts(addons_root) + new_lines, stats = enrich(triples, relmap, inherits, constrains, selections) stats["corpus_triples_in"] = len(triples) stats["relmap_entries"] = len(relmap) stats["inherits_entries"] = sum(len(v) for v in inherits.values()) stats["constrains_entries"] = sum(len(v) for v in constrains.values()) + stats["selections_entries"] = sum(len(v) for v in selections.values()) stats["new_triples"] = len(new_lines) # Preserve the original corpus lines verbatim, then append the sorted new @@ -733,6 +827,7 @@ def main(argv: Optional[List[str]] = None) -> None: f"self_loop={stats['deep_skip_self_loop']}) " f"inherits_from={stats['inherits_from']} " f"validation_kind={stats['validation_kind']} " + f"selection_value={stats['selection_value']} " f"out={stats['corpus_triples_out']}", file=sys.stderr, ) diff --git a/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py b/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py index 5f7965fc..d80a41ab 100644 --- a/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py +++ b/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py @@ -593,5 +593,137 @@ def test_name_plus_inherit_binds_to_name_only(self): self.assertEqual(c, {("account_move", "_check_subject"): {"presence"}}) +# --------------------------------------------------------------------------- +# PR #529 — selection_value (P3) +# --------------------------------------------------------------------------- + + +def _scan_sel(src): + """Run `_scan_file` over a synthetic module; return the selections dict.""" + from odoo_blueprint_extractor.spo_enrich import _scan_file + relmap, inherits, constrains, selections = {}, {}, {}, {} + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: + f.write(src) + path = f.name + try: + _scan_file(path, relmap, inherits, constrains, selections) + finally: + os.unlink(path) + return selections + + +def _classify_sel(src): + """Classify a single synthetic `fields.Selection(...)` call.""" + import ast as _ast + from odoo_blueprint_extractor.spo_enrich import _extract_selection_values + tree = _ast.parse(src) + call = next(n for n in _ast.walk(tree) if isinstance(n, _ast.Call)) + return _extract_selection_values(call) + + +class TestSelectionExtraction(unittest.TestCase): + def test_list_of_tuples_positional(self): + vals = _classify_sel( + "fields.Selection([('draft','Draft'),('posted','Posted'),('cancel','Cancel')])" + ) + self.assertEqual(vals, ["draft", "posted", "cancel"]) + + def test_selection_kwarg(self): + vals = _classify_sel( + "fields.Selection(selection=[('a','A'),('b','B')], string='X')" + ) + self.assertEqual(vals, ["a", "b"]) + + def test_dynamic_method_name_skipped(self): + # selection='_compute_states' is a compute method ref → no static values. + vals = _classify_sel("fields.Selection(selection='_compute_states')") + self.assertEqual(vals, []) + + def test_bare_name_constant_skipped(self): + # selection=SOME_CONSTANT — not statically resolvable from this AST. + vals = _classify_sel("fields.Selection(STATE_VALUES)") + self.assertEqual(vals, []) + + def test_dedup_preserves_first_order(self): + vals = _classify_sel( + "fields.Selection([('a','A'),('b','B'),('a','A2')])" + ) + self.assertEqual(vals, ["a", "b"]) + + def test_non_tuple_entries_skipped(self): + # A malformed entry (not a 2-tuple) is skipped, others survive. + vals = _classify_sel("fields.Selection([('a','A'), 'bogus', ('b','B')])") + self.assertEqual(vals, ["a", "b"]) + + +class TestSelectionScan(unittest.TestCase): + """`_scan_file` binds Selection values to model_names (per #525 rule).""" + + def test_selection_bound_to_name_model(self): + sel = _scan_sel( + "class M:\n" + " _name = 'account.move'\n" + " state = fields.Selection([('draft','Draft'),('posted','Posted')])\n" + ) + self.assertEqual(sel, {("account_move", "state"): ["draft", "posted"]}) + + def test_selection_on_inherit_only_binds_to_inherit_zero(self): + sel = _scan_sel( + "class Ext:\n" + " _inherit = ['account.move', 'mail.thread']\n" + " kind = fields.Selection([('x','X'),('y','Y')])\n" + ) + self.assertEqual(sel, {("account_move", "kind"): ["x", "y"]}) + + def test_dynamic_selection_field_records_nothing(self): + sel = _scan_sel( + "class M:\n" + " _name = 'account.move'\n" + " s = fields.Selection(selection='_compute_s')\n" + ) + self.assertEqual(sel, {}) + + +class TestP3SelectionValueEmission(unittest.TestCase): + def _triples(self, field_iri="odoo:account_move.state"): + return [ + t("odoo:account_move", "rdf:type", "ogit:ObjectType"), + ] + + def test_emits_one_triple_per_value(self): + lines, stats = enrich( + self._triples(), + RELMAP, + selections={("account_move", "state"): ["draft", "posted", "cancel"]}, + ) + self.assertEqual(stats["selection_value"], 3) + joined = "\n".join(lines) + for v in ("draft", "posted", "cancel"): + self.assertIn( + f'{{"s":"odoo:account_move.state","p":"selection_value","o":"{v}"', + joined, + ) + + def test_skip_unknown_model(self): + triples = [t("odoo:account_move", "rdf:type", "ogit:ObjectType")] + lines, stats = enrich( + triples, RELMAP, selections={("mystery_model", "s"): ["a"]} + ) + self.assertEqual(stats["selection_value"], 0) + self.assertEqual(stats["selection_skip_unknown_model"], 1) + self.assertEqual(lines, []) + + def test_dedup_against_existing(self): + triples = [ + t("odoo:account_move", "rdf:type", "ogit:ObjectType"), + t("odoo:account_move.state", "selection_value", "draft"), + ] + _, stats = enrich( + triples, RELMAP, selections={("account_move", "state"): ["draft", "posted"]} + ) + # 'draft' already present → only 'posted' is new. + self.assertEqual(stats["selection_value"], 1) + + if __name__ == "__main__": unittest.main(verbosity=2) From 502a255d202382224fdf7da62a94054f11564f31 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 07:07:12 +0000 Subject: [PATCH 2/2] =?UTF-8?q?refactor(odoo):=20Core-first=20correction?= =?UTF-8?q?=20=E2=80=94=20structural=20facts=20home=20in=20typed=20Core,?= =?UTF-8?q?=20harvest=20is=20subordinate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/board/EPIPHANIES.md | 20 +- .../src/odoo_blueprint/mod.rs | 7 + .../src/odoo_blueprint/structural.rs | 233 ++++++++++++++++++ .../odoo_blueprint_extractor/spo_enrich.py | 26 ++ 4 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 crates/lance-graph-ontology/src/odoo_blueprint/structural.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index ef0d4f1b..0d4ad881 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,10 +1,24 @@ +## 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. + +**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. + +**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. + +**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`. + +**`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. + +**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. + ## 2026-06-18 — E-ODOO-SPO-SELECTION-VALUE — spo_enrich gains selection_value (wishlist P3); corpus regen pending Odoo source -**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:., selection_value, "")` 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`. +**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:., selection_value, "")` 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`. -**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). +**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). -**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). +**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. ## 2026-06-18 — E-WITNESS-ARC-TWO-OBJECTS-1 — "witness arc" names TWO different objects; do NOT unify them under a `WitnessArcEvaluator` trait diff --git a/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs b/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs index df292029..2a34d0fe 100644 --- a/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs +++ b/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs @@ -93,6 +93,13 @@ pub mod style_recipe; // build.rs → OUT_DIR → include!(). See op_emitter::emit_op_dispatch. pub mod op_emitter; +/// Structural Core extension — typed home for `inherits_from` + +/// `selection_value` (the two structural gaps that do not fit the +/// 3 554-literal `OdooField` / 404-literal `OdooEntity` mega-structs). +/// Core is authoritative; the `spo_enrich.py` harvest is the subordinate +/// Extracted-leg breadth feeder. See the module doc for the full rationale. +pub mod structural; + // ─── Top-level entity ───────────────────────────────────────────────────── /// Which ORM base class the entity inherits from. diff --git a/crates/lance-graph-ontology/src/odoo_blueprint/structural.rs b/crates/lance-graph-ontology/src/odoo_blueprint/structural.rs new file mode 100644 index 00000000..558d45dc --- /dev/null +++ b/crates/lance-graph-ontology/src/odoo_blueprint/structural.rs @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Structural Core extension — the typed home for `inherits_from` and +//! `selection_value`. +//! +//! # Why this module exists (Core-first correction, 2026-06-18) +//! +//! Two structural facts about an Odoo model — its `_inherit`/`_inherits` +//! **mixin chain** and a `fields.Selection` field's **allowed value set** — +//! are *Core* properties (identity / composition / value-domain), not +//! behavioural harvest. Per the Core-first transcode doctrine they belong in +//! the deliberate typed Core ([`super::OdooEntity`] / [`super::OdooField`]), +//! the single source of truth, **not** re-inferred onto the flat SPO ndjson +//! by a separate AST pass. +//! +//! They could not be added as *fields* on the existing structs: `OdooField` +//! has **3 554** literal sites and `OdooEntity` **404** across `l1..l15.rs`, +//! none using a constructor — adding a field would break every one. For a +//! literal sea that size the doctrine-correct "extend the Core deliberately" +//! is a **typed side-table**, which is what this module is. It is still Core +//! (`lance-graph-ontology::odoo_blueprint`), still authoritative, still +//! `OdooConfidence::Curated`-grade; it simply lives beside the mega-structs +//! instead of inside them. +//! +//! # Direction of truth: Core → SPO, never the reverse +//! +//! [`project_inherits_from`] and [`project_selection_value`] emit SPO triples +//! **from** this typed Core. The `spo_enrich.py` AST harvest is the +//! **Extracted leg** (per `odoo-extraction-strategies-v1.md`) — a *breadth* +//! feeder for the ~322 ObjectTypes the curated Core has not yet reached. On +//! convergence in the `SpoStore` the curated Core (this module, +//! `0.95/0.90`) **wins** over the harvest's extracted confidence for any +//! model it covers. The harvest never becomes the home for a structural +//! fact; it fills in where the Core is silent. +//! +//! Behavioural predicates (`reads_field` deep lifts, `emitted_by`, +//! transitive `depends_on`) are genuine harvest and stay in `spo_enrich.py` +//! — they describe a method *body*, not the model's structure. + +/// One model's `_inherit` / `_inherits` mixin chain — the composition gap +/// the `OdooEntity` mega-struct does not carry (`OdooEntityKind` records the +/// ORM *base class* `Model`/`Transient`/`Abstract`, not the mixin list). +/// +/// `bases` are dotted Odoo model names (`"mail.activity.mixin"`); the SPO +/// projection underscores them to match the corpus IRI convention. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OdooInherits { + /// Owning model, dotted (`"account.move"`). + pub model: &'static str, + /// Mixin bases this model `_inherit`s, in declaration order. + pub bases: &'static [&'static str], +} + +/// One `fields.Selection` field's statically-known value domain — the gap +/// `OdooFieldKind::Selection` flags but `OdooField` does not store (only the +/// `state` field's domain is reachable today, via `OdooStateMachine`). +/// +/// `values` are the stored *keys* (the first element of each `('key', +/// 'Label')` tuple), in declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OdooFieldSelection { + /// Owning model, dotted (`"account.move"`). + pub model: &'static str, + /// Selection field name (`"state"`, `"move_type"`). + pub field: &'static str, + /// Allowed value keys, in source order. + pub values: &'static [&'static str], +} + +// ─── Curated data — canonical `account.move` (grounded, not invented) ────── +// +// `INHERITS` is grounded in the #527-regenerated corpus +// (`grep '"s":"odoo:account_move","p":"inherits_from"'`): the corpus captured +// `mail_activity_mixin` + `sequence_mixin` (it drops bases that are not +// themselves corpus ObjectTypes — `mail.thread` / `portal.mixin` fell outside +// that boundary). The curated Core records the corpus-confirmed pair; a future +// L-doc curation pass may widen it to the full real chain (the Core is allowed +// to exceed the harvest's ObjectType-boundary, since it is authoritative). +// +// `FIELD_SELECTIONS` uses the canonical, stable Odoo `account.move` value sets +// (`state`, `move_type`) — standard across Odoo versions, verifiable against +// `addons/account/models/account_move.py`. + +/// Curated inherit-chains. APPEND-ONLY as L-doc curation reaches more models. +pub const INHERITS: &[OdooInherits] = &[ + OdooInherits { + model: "account.move", + bases: &["mail.activity.mixin", "sequence.mixin"], + }, + OdooInherits { + model: "account.move.line", + bases: &["analytic.mixin"], + }, +]; + +/// Curated Selection value domains. APPEND-ONLY. +pub const FIELD_SELECTIONS: &[OdooFieldSelection] = &[ + OdooFieldSelection { + model: "account.move", + field: "state", + values: &["draft", "posted", "cancel"], + }, + OdooFieldSelection { + model: "account.move", + field: "move_type", + values: &[ + "entry", + "out_invoice", + "out_refund", + "in_invoice", + "in_refund", + "out_receipt", + "in_receipt", + ], + }, +]; + +// ─── Core → SPO projection ───────────────────────────────────────────────── + +/// `account.move` → `account_move` (corpus IRI local-part convention). +fn underscore(dotted: &str) -> String { + dotted.replace('.', "_") +} + +/// One projected SPO triple: `(subject, predicate, object)`. Truth values are +/// fixed at the curated grade `(0.95, 0.90)` by the projection (declared, +/// authoritative), so callers serialising to ndjson append `,"f":0.95,"c":0.9`. +pub type SpoTriple = (String, &'static str, String); + +/// Project the curated inherit-chains into `inherits_from` SPO triples, +/// `(odoo:, inherits_from, odoo:)`. Both endpoints underscored. +/// +/// This is the authoritative source for `inherits_from` on every model in +/// [`INHERITS`]; the harvest only supplies models absent here. +#[must_use] +pub fn project_inherits_from(table: &[OdooInherits]) -> Vec { + let mut out = Vec::new(); + for row in table { + let child = format!("odoo:{}", underscore(row.model)); + for base in row.bases { + out.push((child.clone(), "inherits_from", format!("odoo:{}", underscore(base)))); + } + } + out +} + +/// Project the curated Selection domains into `selection_value` SPO triples, +/// `(odoo:., selection_value, "")`. One per value, source +/// order preserved. +#[must_use] +pub fn project_selection_value(table: &[OdooFieldSelection]) -> Vec { + let mut out = Vec::new(); + for row in table { + let subj = format!("odoo:{}.{}", underscore(row.model), row.field); + for v in row.values { + out.push((subj.clone(), "selection_value", (*v).to_string())); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inherits_projection_underscores_both_endpoints() { + let triples = project_inherits_from(INHERITS); + assert!(triples.contains(&( + "odoo:account_move".to_string(), + "inherits_from", + "odoo:mail_activity_mixin".to_string(), + ))); + assert!(triples.contains(&( + "odoo:account_move".to_string(), + "inherits_from", + "odoo:sequence_mixin".to_string(), + ))); + // account.move.line → account_move_line, analytic.mixin → analytic_mixin + assert!(triples.contains(&( + "odoo:account_move_line".to_string(), + "inherits_from", + "odoo:analytic_mixin".to_string(), + ))); + } + + #[test] + fn inherits_projection_matches_527_corpus() { + // The two account.move bases the projection emits are exactly the pair + // the #527 corpus regen captured — the typed Core is consistent with + // (not contradicting) the harvest, and is the authoritative home. + let triples = project_inherits_from(INHERITS); + let am_bases: Vec<&str> = triples + .iter() + .filter(|(s, _, _)| s == "odoo:account_move") + .map(|(_, _, o)| o.as_str()) + .collect(); + assert_eq!(am_bases, vec!["odoo:mail_activity_mixin", "odoo:sequence_mixin"]); + } + + #[test] + fn selection_projection_one_triple_per_value_in_order() { + let triples = project_selection_value(FIELD_SELECTIONS); + let state_vals: Vec<&str> = triples + .iter() + .filter(|(s, _, _)| s == "odoo:account_move.state") + .map(|(_, _, o)| o.as_str()) + .collect(); + assert_eq!(state_vals, vec!["draft", "posted", "cancel"]); + + let move_type_vals: Vec<&str> = triples + .iter() + .filter(|(s, _, _)| s == "odoo:account_move.move_type") + .map(|(_, _, o)| o.as_str()) + .collect(); + assert_eq!(move_type_vals[0], "entry"); + assert_eq!(move_type_vals.len(), 7); + } + + #[test] + fn selection_subject_is_field_iri_not_model() { + let triples = project_selection_value(FIELD_SELECTIONS); + // selection_value keys on the FIELD, never the model. + assert!(triples.iter().all(|(s, _, _)| s.contains('.'))); + } + + #[test] + fn registries_are_nonempty_and_curated() { + assert!(!INHERITS.is_empty()); + assert!(!FIELD_SELECTIONS.is_empty()); + } +} diff --git a/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py b/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py index 313bb55c..657eb375 100644 --- a/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py +++ b/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py @@ -83,6 +83,32 @@ `od_ontology::RecomputeDag` (which today sees only the relation read, leaving the line→move dependency structurally invisible). +# ARCHITECTURE NOTE — this is the breadth feeder, NOT the home (2026-06-18) + +The *structural* predicates here — `target` / `inverse_name` / `inherits_from` +/ `selection_value` — are **Core** facts (relations / composition / value +domain). Their AUTHORITATIVE home is the typed `OdooEntity` Core in +`lance-graph-ontology::odoo_blueprint` (`OdooField.target` already exists; +`inherits_from` + `selection_value` live in the +`odoo_blueprint::structural` side-table). This module is the **Extracted +leg** (per `.claude/knowledge/odoo-extraction-strategies-v1.md`): a *breadth* +feeder that AST-walks the full Odoo source for the ~322 ObjectTypes the +curated Core has not yet reached. On convergence in the `SpoStore` the +curated Core (`OdooConfidence::Curated`) WINS over this leg's extracted +confidence for any model it covers — the harvest never becomes the home for +a structural fact, it fills in where the Core is silent. + +Reading this file as "where structural predicates live" is the +self-fulfilling drift the 2026-06-18 Core-first correction reversed +(see `EPIPHANIES.md` E-ODOO-CORE-FIRST-STRUCTURAL). Do NOT add +`virtually_overrides` here — it is a ClassView/Core MRO capability, not a +harvest predicate. + +The *behavioural* predicates (`reads_field` deep lifts, `emitted_by`, +transitive `depends_on`, `raises`, `validation_kind` body classification) +ARE genuine harvest — they describe a method *body*, not the model's +structure — and correctly live here. + # Why a separate enrichment pass (not the ORM extractor) The ORM extractor (`parsers/`, `emitters/`) emits typed Rust `OdooEntity`