Skip to content

Commit 6ded9e0

Browse files
committed
feat(odoo-spo): selection_value (wishlist P3)
Adds a fifth enrichment pass to spo_enrich.py: `fields.Selection` declarations with a statically-resolvable list of 2-tuples emit one `(odoo:<model>.<field>, selection_value, "<key>")` 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
1 parent 402ab09 commit 6ded9e0

4 files changed

Lines changed: 243 additions & 6 deletions

File tree

.claude/board/EPIPHANIES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 2026-06-18 — E-ODOO-SPO-SELECTION-VALUE — spo_enrich gains selection_value (wishlist P3); corpus regen pending Odoo source
2+
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`.
4+
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).
6+
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).
8+
19
## 2026-06-18 — E-WITNESS-ARC-TWO-OBJECTS-1 — "witness arc" names TWO different objects; do NOT unify them under a `WitnessArcEvaluator` trait
210

311
**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.

crates/lance-graph/src/graph/spo/odoo_ontology.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
//! | `inverse_name` | `odoo:<fam>.<rel>` | `"<inverse>"` | One2many/inverse (declared) |
2727
//! | `inherits_from` | `odoo:<family>` | `odoo:<base_family>` | `_inherit`/`_inherits` base (declared) |
2828
//! | `validation_kind` | `odoo:<fam>.<fn>` | `"<kind>"` | `@api.constrains` body pattern (inferred) |
29+
//! | `selection_value` | `odoo:<fam>.<field>` | `"<value_key>"` | `fields.Selection` enum key (declared) |
2930
//!
3031
//! ## FK-target + deep-read enrichment (`spo_enrich`)
3132
//!
@@ -183,6 +184,7 @@ mod tests {
183184
"inverse_name" => "inverse_name",
184185
"inherits_from" => "inherits_from",
185186
"validation_kind" => "validation_kind",
187+
"selection_value" => "selection_value",
186188
_ => "other",
187189
})
188190
.or_default() += 1;

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

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""SPO corpus enrichment — FK target/inverse_name (P1) + deep reads_field (P0)
2-
+ inherits_from (P1b, ruff#19) + validation_kind (P2, ruff#21).
2+
+ inherits_from (P1b, ruff#19) + validation_kind (P2, ruff#21)
3+
+ selection_value (P3).
34
45
Stdlib-only Python 3. Reads the Odoo ORM source (the same addons tree the
56
ORM extractor parses) to build a relation-target map, then enriches an
@@ -60,6 +61,22 @@
6061
extension classes bind to `_inherit[0]` (mirrors `_scan_file`'s field
6162
binding decision per #525 codex review).
6263
64+
* **P3 — `selection_value`** (UPSTREAM_WISHLIST P3): for every
65+
`fields.Selection([('draft','Draft'), ('posted','Posted'), …])`
66+
declaration with a *statically resolvable* list of 2-tuples, emit one
67+
triple per value KEY (the first tuple element), keyed by the field IRI::
68+
69+
(odoo:account_move.state, selection_value, "draft")
70+
(odoo:account_move.state, selection_value, "posted")
71+
(odoo:account_move.state, selection_value, "cancel")
72+
73+
Enables the consumer to lower a Selection field to
74+
`DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']`.
75+
Dynamic selections (`selection='_compute_states'`, a bare Name constant,
76+
or `related=`) are skipped — their values aren't statically knowable.
77+
Source order is preserved. Selection fields bind to the same
78+
`model_names` as relational fields (`_name`, else `_inherit[0]`).
79+
6380
This is *in addition* to the existing shallow relation read
6481
`(_compute_amount, reads_field, odoo:account_move.line_ids)`. The deep
6582
read makes the cross-model recompute-ordering edge visible to
@@ -110,12 +127,49 @@
110127
# validation_kind is an AST-pattern classification of a method body
111128
# (heuristic, not authoritative).
112129
VALIDATION_KIND_TRUTH = (0.85, 0.75)
130+
# selection_value is a statically-resolved enum key from a Selection
131+
# declaration (authoritative — read straight from the field decorator).
132+
SELECTION_VALUE_TRUTH = (0.95, 0.90)
113133

114134
# Recognised `validation_kind` strings (matches ruff#21's Rails set where
115135
# semantics overlap; Odoo-specific patterns like `lookup` extend it).
116136
_VALIDATION_KINDS = ("presence", "uniqueness", "range", "format", "lookup")
117137

118138

139+
def _extract_selection_values(call: ast.Call) -> List[str]:
140+
"""Pull the value KEYS from a `fields.Selection([('k','Label'), …])`
141+
declaration. The selection list is the first positional arg OR the
142+
`selection=` kwarg; each entry is a 2-tuple whose first element is the
143+
stored key.
144+
145+
Returns the keys in source order, de-duplicated (first occurrence wins).
146+
Returns `[]` for dynamic selections — a bare Name (constant reference),
147+
a string (compute-method name), `related=`, or any entry whose first
148+
element isn't a string constant. Those values aren't statically known.
149+
"""
150+
sel: Optional[ast.expr] = None
151+
for kw in call.keywords:
152+
if kw.arg == "selection":
153+
sel = kw.value
154+
break
155+
if sel is None and call.args:
156+
sel = call.args[0]
157+
if not isinstance(sel, (ast.List, ast.Tuple)):
158+
# `selection='_compute_x'` (str), a Name constant, or related= → skip.
159+
return []
160+
161+
out: List[str] = []
162+
seen: Set[str] = set()
163+
for elt in sel.elts:
164+
if not isinstance(elt, (ast.Tuple, ast.List)) or len(elt.elts) < 1:
165+
continue
166+
key = _const_str(elt.elts[0])
167+
if key is not None and key not in seen:
168+
seen.add(key)
169+
out.append(key)
170+
return out
171+
172+
119173
def _is_uniqueness_call(node: ast.expr) -> bool:
120174
"""True iff `node` is a `<recordset>.search_count(...)` call — the LHS
121175
of the canonical Odoo uniqueness pattern. Used to suppress `range` for
@@ -222,6 +276,7 @@ def _scan_file(
222276
relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]],
223277
inherits: Optional[Dict[str, Set[str]]] = None,
224278
constrains: Optional[Dict[Tuple[str, str], Set[str]]] = None,
279+
selections: Optional[Dict[Tuple[str, str], List[str]]] = None,
225280
) -> None:
226281
"""Parse one .py file; record every relational field on every named model.
227282
Optionally also record `inherits_from` edges and `@api.constrains`
@@ -262,6 +317,7 @@ def _scan_file(
262317
inherits_models: List[str] = [] # _inherits dict keys (delegation)
263318
local_fields: Dict[str, Tuple[str, Optional[str]]] = {}
264319
local_constrains: List[Tuple[str, ast.FunctionDef]] = []
320+
local_selections: Dict[str, List[str]] = {}
265321

266322
for stmt in node.body:
267323
# Method definitions — gather @api.constrains methods for P2.
@@ -311,6 +367,12 @@ def _scan_file(
311367
):
312368
continue
313369
field_type = stmt.value.func.attr
370+
# Selection field — extract statically-known value keys (P3).
371+
if field_type == "Selection" and selections is not None:
372+
vals = _extract_selection_values(stmt.value)
373+
if vals:
374+
local_selections[target_name] = vals
375+
continue
314376
if field_type not in RELATIONAL_KINDS:
315377
continue
316378

@@ -359,6 +421,11 @@ def _scan_file(
359421
# Last write wins across _inherit reopenings of the same model;
360422
# comodel for a given (model, field) is stable in practice.
361423
relmap[(mu, field_name)] = (comodel, inverse)
424+
if selections is not None:
425+
for field_name, vals in local_selections.items():
426+
# Last write wins (a later reopening that redeclares the
427+
# Selection with a fuller value list supersedes).
428+
selections[(mu, field_name)] = vals
362429

363430
# ── P1b: inherits_from edges ─────────────────────────────────────
364431
# Only when the class declares a NEW model (_name is set). An
@@ -393,21 +460,24 @@ def build_all_facts(
393460
Dict[Tuple[str, str], Tuple[str, Optional[str]]],
394461
Dict[str, Set[str]],
395462
Dict[Tuple[str, str], Set[str]],
463+
Dict[Tuple[str, str], List[str]],
396464
]:
397-
"""Single-pass scan that populates (relmap, inherits, constrains).
465+
"""Single-pass scan that populates (relmap, inherits, constrains, selections).
398466
399467
Returns:
400468
relmap — (model_us, field) → (comodel_dotted, inverse_or_None)
401469
inherits — model_us → set(base_us) from `_inherit` + `_inherits`
402470
constrains — (model_us, method) → set(kind) from `@api.constrains`
471+
selections — (model_us, field) → [value_key, …] from `fields.Selection`
403472
"""
404473
relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]] = {}
405474
inherits: Dict[str, Set[str]] = {}
406475
constrains: Dict[Tuple[str, str], Set[str]] = {}
476+
selections: Dict[Tuple[str, str], List[str]] = {}
407477
pattern = os.path.join(addons_root, "**", "*.py")
408478
for path in glob.iglob(pattern, recursive=True):
409-
_scan_file(path, relmap, inherits, constrains)
410-
return relmap, inherits, constrains
479+
_scan_file(path, relmap, inherits, constrains, selections)
480+
return relmap, inherits, constrains, selections
411481

412482

413483
def build_relation_map(addons_root: str) -> Dict[Tuple[str, str], Tuple[str, Optional[str]]]:
@@ -468,6 +538,7 @@ def enrich(
468538
relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]],
469539
inherits: Optional[Dict[str, Set[str]]] = None,
470540
constrains: Optional[Dict[Tuple[str, str], Set[str]]] = None,
541+
selections: Optional[Dict[Tuple[str, str], List[str]]] = None,
471542
) -> Tuple[List[str], dict]:
472543
"""Compute the additive enrichment lines + a stats dict.
473544
@@ -544,6 +615,8 @@ def enrich(
544615
"inherits_skip_unknown_base": 0,
545616
"validation_kind": 0,
546617
"validation_skip_unknown_method": 0,
618+
"selection_value": 0,
619+
"selection_skip_unknown_model": 0,
547620
}
548621

549622
# ── P1: target / inverse_name ─────────────────────────────────────────
@@ -661,19 +734,40 @@ def enrich(
661734
existing_spo.add(spo)
662735
stats["validation_kind"] += 1
663736

737+
# ── P3: selection_value ───────────────────────────────────────────────
738+
# One triple per statically-known enum key on a Selection field. Scoped to
739+
# corpus-declared ObjectTypes (the additive boundary): never invent a
740+
# selection on an unknown model. Value order preserved (source order).
741+
if selections:
742+
for (model_us, field_name), vals in sorted(selections.items()):
743+
if model_us not in object_type_models:
744+
stats["selection_skip_unknown_model"] += 1
745+
continue
746+
field_iri = f"odoo:{model_us}.{field_name}"
747+
for val in vals:
748+
spo = (field_iri, "selection_value", val)
749+
if spo in existing_spo:
750+
continue
751+
new_lines.append(
752+
triple_line(field_iri, "selection_value", val, *SELECTION_VALUE_TRUTH)
753+
)
754+
existing_spo.add(spo)
755+
stats["selection_value"] += 1
756+
664757
new_lines.sort()
665758
return new_lines, stats
666759

667760

668761
def run(corpus_path: str, addons_root: str, out_path: str) -> dict:
669762
"""Enrich `corpus_path` using `addons_root`, write to `out_path`. Returns stats."""
670763
triples = load_corpus(corpus_path)
671-
relmap, inherits, constrains = build_all_facts(addons_root)
672-
new_lines, stats = enrich(triples, relmap, inherits, constrains)
764+
relmap, inherits, constrains, selections = build_all_facts(addons_root)
765+
new_lines, stats = enrich(triples, relmap, inherits, constrains, selections)
673766
stats["corpus_triples_in"] = len(triples)
674767
stats["relmap_entries"] = len(relmap)
675768
stats["inherits_entries"] = sum(len(v) for v in inherits.values())
676769
stats["constrains_entries"] = sum(len(v) for v in constrains.values())
770+
stats["selections_entries"] = sum(len(v) for v in selections.values())
677771
stats["new_triples"] = len(new_lines)
678772

679773
# Preserve the original corpus lines verbatim, then append the sorted new
@@ -733,6 +827,7 @@ def main(argv: Optional[List[str]] = None) -> None:
733827
f"self_loop={stats['deep_skip_self_loop']}) "
734828
f"inherits_from={stats['inherits_from']} "
735829
f"validation_kind={stats['validation_kind']} "
830+
f"selection_value={stats['selection_value']} "
736831
f"out={stats['corpus_triples_out']}",
737832
file=sys.stderr,
738833
)

tools/odoo-blueprint-extractor/tests/test_spo_enrich.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,5 +593,137 @@ def test_name_plus_inherit_binds_to_name_only(self):
593593
self.assertEqual(c, {("account_move", "_check_subject"): {"presence"}})
594594

595595

596+
# ---------------------------------------------------------------------------
597+
# PR #529 — selection_value (P3)
598+
# ---------------------------------------------------------------------------
599+
600+
601+
def _scan_sel(src):
602+
"""Run `_scan_file` over a synthetic module; return the selections dict."""
603+
from odoo_blueprint_extractor.spo_enrich import _scan_file
604+
relmap, inherits, constrains, selections = {}, {}, {}, {}
605+
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
606+
f.write(src)
607+
path = f.name
608+
try:
609+
_scan_file(path, relmap, inherits, constrains, selections)
610+
finally:
611+
os.unlink(path)
612+
return selections
613+
614+
615+
def _classify_sel(src):
616+
"""Classify a single synthetic `fields.Selection(...)` call."""
617+
import ast as _ast
618+
from odoo_blueprint_extractor.spo_enrich import _extract_selection_values
619+
tree = _ast.parse(src)
620+
call = next(n for n in _ast.walk(tree) if isinstance(n, _ast.Call))
621+
return _extract_selection_values(call)
622+
623+
624+
class TestSelectionExtraction(unittest.TestCase):
625+
def test_list_of_tuples_positional(self):
626+
vals = _classify_sel(
627+
"fields.Selection([('draft','Draft'),('posted','Posted'),('cancel','Cancel')])"
628+
)
629+
self.assertEqual(vals, ["draft", "posted", "cancel"])
630+
631+
def test_selection_kwarg(self):
632+
vals = _classify_sel(
633+
"fields.Selection(selection=[('a','A'),('b','B')], string='X')"
634+
)
635+
self.assertEqual(vals, ["a", "b"])
636+
637+
def test_dynamic_method_name_skipped(self):
638+
# selection='_compute_states' is a compute method ref → no static values.
639+
vals = _classify_sel("fields.Selection(selection='_compute_states')")
640+
self.assertEqual(vals, [])
641+
642+
def test_bare_name_constant_skipped(self):
643+
# selection=SOME_CONSTANT — not statically resolvable from this AST.
644+
vals = _classify_sel("fields.Selection(STATE_VALUES)")
645+
self.assertEqual(vals, [])
646+
647+
def test_dedup_preserves_first_order(self):
648+
vals = _classify_sel(
649+
"fields.Selection([('a','A'),('b','B'),('a','A2')])"
650+
)
651+
self.assertEqual(vals, ["a", "b"])
652+
653+
def test_non_tuple_entries_skipped(self):
654+
# A malformed entry (not a 2-tuple) is skipped, others survive.
655+
vals = _classify_sel("fields.Selection([('a','A'), 'bogus', ('b','B')])")
656+
self.assertEqual(vals, ["a", "b"])
657+
658+
659+
class TestSelectionScan(unittest.TestCase):
660+
"""`_scan_file` binds Selection values to model_names (per #525 rule)."""
661+
662+
def test_selection_bound_to_name_model(self):
663+
sel = _scan_sel(
664+
"class M:\n"
665+
" _name = 'account.move'\n"
666+
" state = fields.Selection([('draft','Draft'),('posted','Posted')])\n"
667+
)
668+
self.assertEqual(sel, {("account_move", "state"): ["draft", "posted"]})
669+
670+
def test_selection_on_inherit_only_binds_to_inherit_zero(self):
671+
sel = _scan_sel(
672+
"class Ext:\n"
673+
" _inherit = ['account.move', 'mail.thread']\n"
674+
" kind = fields.Selection([('x','X'),('y','Y')])\n"
675+
)
676+
self.assertEqual(sel, {("account_move", "kind"): ["x", "y"]})
677+
678+
def test_dynamic_selection_field_records_nothing(self):
679+
sel = _scan_sel(
680+
"class M:\n"
681+
" _name = 'account.move'\n"
682+
" s = fields.Selection(selection='_compute_s')\n"
683+
)
684+
self.assertEqual(sel, {})
685+
686+
687+
class TestP3SelectionValueEmission(unittest.TestCase):
688+
def _triples(self, field_iri="odoo:account_move.state"):
689+
return [
690+
t("odoo:account_move", "rdf:type", "ogit:ObjectType"),
691+
]
692+
693+
def test_emits_one_triple_per_value(self):
694+
lines, stats = enrich(
695+
self._triples(),
696+
RELMAP,
697+
selections={("account_move", "state"): ["draft", "posted", "cancel"]},
698+
)
699+
self.assertEqual(stats["selection_value"], 3)
700+
joined = "\n".join(lines)
701+
for v in ("draft", "posted", "cancel"):
702+
self.assertIn(
703+
f'{{"s":"odoo:account_move.state","p":"selection_value","o":"{v}"',
704+
joined,
705+
)
706+
707+
def test_skip_unknown_model(self):
708+
triples = [t("odoo:account_move", "rdf:type", "ogit:ObjectType")]
709+
lines, stats = enrich(
710+
triples, RELMAP, selections={("mystery_model", "s"): ["a"]}
711+
)
712+
self.assertEqual(stats["selection_value"], 0)
713+
self.assertEqual(stats["selection_skip_unknown_model"], 1)
714+
self.assertEqual(lines, [])
715+
716+
def test_dedup_against_existing(self):
717+
triples = [
718+
t("odoo:account_move", "rdf:type", "ogit:ObjectType"),
719+
t("odoo:account_move.state", "selection_value", "draft"),
720+
]
721+
_, stats = enrich(
722+
triples, RELMAP, selections={("account_move", "state"): ["draft", "posted"]}
723+
)
724+
# 'draft' already present → only 'posted' is new.
725+
self.assertEqual(stats["selection_value"], 1)
726+
727+
596728
if __name__ == "__main__":
597729
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)