Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .claude/board/EPIPHANIES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
## 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; **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`.

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

**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.
Expand Down
7 changes: 7 additions & 0 deletions crates/lance-graph-ontology/src/odoo_blueprint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
233 changes: 233 additions & 0 deletions crates/lance-graph-ontology/src/odoo_blueprint/structural.rs
Original file line number Diff line number Diff line change
@@ -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:<model>, inherits_from, odoo:<base>)`. 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<SpoTriple> {
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:<model>.<field>, selection_value, "<key>")`. One per value, source
/// order preserved.
#[must_use]
pub fn project_selection_value(table: &[OdooFieldSelection]) -> Vec<SpoTriple> {
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());
}
}
2 changes: 2 additions & 0 deletions crates/lance-graph/src/graph/spo/odoo_ontology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
//! | `inverse_name` | `odoo:<fam>.<rel>` | `"<inverse>"` | One2many/inverse (declared) |
//! | `inherits_from` | `odoo:<family>` | `odoo:<base_family>` | `_inherit`/`_inherits` base (declared) |
//! | `validation_kind` | `odoo:<fam>.<fn>` | `"<kind>"` | `@api.constrains` body pattern (inferred) |
//! | `selection_value` | `odoo:<fam>.<field>` | `"<value_key>"` | `fields.Selection` enum key (declared) |
//!
//! ## FK-target + deep-read enrichment (`spo_enrich`)
//!
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading