Skip to content

Commit 1c32577

Browse files
committed
feat(ar_shape): mixin = family/group node — Rails include + Odoo _inherit are the same members/memberof primitive (#545..#551)
Operator nudge (2026-06-19): "the family nodes introduced in lance-graph 545..551 could serve as mixin — group.memberof/members where group is the mixin node." This RESOLVES a divergence I flagged wrong earlier this session. When asked whether the Rails spine could verify Odoo AR-shapedness, I said yes but called Odoo's _inherit "a non-AR shape with no Rails analog." That was wrong: the Rails analog is include (concerns), and BOTH lower to the family-node members/memberof primitive from #549 (graph::mailbox_scan::{members, memberof, BasinOf}). A mixin IS a family/group node; include/_inherit IS the memberof edge. New surface: - mixin_members(triples, ns, is_rails) -> BTreeMap<group, BTreeSet<member>> Reads includes_module (Rails) or inherits_from (Odoo), ns-strips, returns the `members` direction (memberof is the transpose). - shared_mixin_groups(members, min) -> Vec<group> The ≥2-member fan-out filter: a group shared by ≥2 classes is a genuine mixin; a single-member group is an STI base / model extension, not a mixin. Same distinction members(basin) draws in #549. Grounded in the harvest (not asserted): - OSB carries 37 includes_module triples (Client→PublicActivity::Model, Estimate→Trackstamps/DateFormats). - Odoo carries 166 inherits_from triples; mail_thread is a group node with 70+ members (sale_order, account_account, purchase_order, ...). account_move rides mail_activity_mixin + sequence_mixin, NOT mail_thread directly — the test preserves the harvest's distinction. - Cross-curator semantic convergence: OSB PublicActivity::Model (activity tracking) ≈ Odoo mail_thread / mail_activity_mixin. Both curators independently grew an activity mixin group. 4 tests, all green: - odoo_mail_thread_is_a_family_group_node_with_many_members - osb_rails_public_activity_model_is_a_family_group_node - rails_include_and_odoo_inherit_are_the_same_family_node_primitive (the divergence-resolution test) - single_member_extension_is_not_a_mixin_group (fan-out honesty) Plus all 31 prior tests still green → 35/35 total. ar_shape clippy-clean. The lesson: an apparent "Odoo non-AR divergence" should first be checked against the lance-graph substrate primitives (#545..#551 members/memberof, the family-node tree) before being called a divergence — the substrate already had the home for it. The mixin group node carries shared behaviour down to members, which is E-FAMILY-NODE-IS-META-AWARENESS instantiated for ERP mixins (parent = coarse summary members inherit). EPIPHANIES E-OGAR-AR-SHAPE-SMOKE-6 prepended (includes the correction of my earlier wrong claim). Cross-refs: - E-BASIN-IS-A-NODE + E-FAMILY-NODE-IS-META-AWARENESS + E-GUID-SELF-ROUTES-THE-BASIN-TREE (the #545..#551 family-node arc) - graph::mailbox_scan::{members, memberof} (#549 substrate primitive) - E-OGAR-AR-SHAPE-SMOKE-5 (the concept-edge AST test; mixin membership is the inheritance-edge complement to the composition-edge graph) - AdaWorldAPI/OGAR project_actor (STI-collapse used the same shape) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xzyc27Nx3f8WC5KzwfWfjx
1 parent 5ac1d3a commit 1c32577

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

.claude/board/EPIPHANIES.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
## 2026-06-19 — E-OGAR-AR-SHAPE-SMOKE-6 — a mixin IS a family/group node: Rails `include` and Odoo `_inherit` are the SAME `members`/`memberof` primitive from lance-graph #545..#551 — RESOLVES the earlier-flagged "Odoo _inherit is non-AR" claim (it was wrong)
2+
3+
**Status:** FINDING (operator nudge, 2026-06-19; 35/35 ar_shape tests green, ar_shape clippy-clean). **Corrects an error I posted earlier this session.** When asked "does it make sense to use the Rails-shaped spine to verify Odoo AR-shapedness," I answered yes but flagged Odoo's `_inherit` mixin composition as "a non-AR shape with no Rails analog... closer to Smalltalk/Ruby modules than to AR." The operator nudge: *"the family nodes introduced in lance-graph 545..551 could serve as mixin — group.memberof/members where group is the mixin node."* That dissolves the divergence — the earlier claim was wrong.
4+
5+
**The resolution.** A mixin IS a family/group node (per `E-BASIN-IS-A-NODE` / `E-FAMILY-NODE-IS-META-AWARENESS`, lance-graph #545..#551 `graph::mailbox_scan::{members, memberof, BasinOf}`). `group.members` = the classes that include the mixin; `class.memberof` = the mixin group. Rails `include ModuleX` (concerns) and Odoo `_inherit = ['mail.thread', ...]` are NOT different architectures — they are the SAME `members`/`memberof` edge into a group node. The Rails analog of Odoo `_inherit` is exactly `include`; both lower to family-node membership. Odoo is AR-shape-VERIFIED on mixins, not divergent.
6+
7+
**Grounded in the harvest (not asserted):**
8+
- OSB (Rails) carries **37 `includes_module` triples**: `Client include PublicActivity::Model`, `Estimate include {PublicActivity::Model, Trackstamps, DateFormats}`.
9+
- Odoo carries **166 `inherits_from` triples**: `mail_thread` is a group node with **70+ members** (`sale_order`, `account_account`, `purchase_order`, `res_company`, `stock_picking`, …); `account_move` rides `mail_activity_mixin` + `sequence_mixin` (the harvest's faithful distinction — account_move is NOT a direct mail_thread member, and the test preserves that).
10+
- **Cross-curator semantic convergence:** OSB `PublicActivity::Model` (activity tracking) ≈ Odoo `mail_thread` / `mail_activity_mixin` (chatter + activities). Both curators independently grew an activity-tracking mixin group — the mixin-as-family-node pattern converges even though specific mixin names differ.
11+
12+
**The ≥2-member filter IS the family-node fan-out.** `shared_mixin_groups(members, 2)` keeps only groups with ≥2 members — a genuinely shared mixin (shared behaviour). A single-member "group" (`account_bank_statement_line inherits_from account_move`) is an STI base / model EXTENSION, not a mixin, and is correctly excluded. This is the same structural distinction `members(basin)` draws in #549: a basin node fans out to its children; a mixin group fans out to its members; a single-member parent is an extension, not a group.
13+
14+
**The code (`mixin_members` + `shared_mixin_groups` + 4 tests):** `mixin_members(triples, ns, is_rails) -> BTreeMap<group, BTreeSet<member>>` reads `includes_module` (Rails) or `inherits_from` (Odoo), namespace-strips, returns the `members` direction (the `memberof` inverse is the transpose). `shared_mixin_groups(members, min)` applies the fan-out filter. Tests: `odoo_mail_thread_is_a_family_group_node_with_many_members`, `osb_rails_public_activity_model_is_a_family_group_node`, `rails_include_and_odoo_inherit_are_the_same_family_node_primitive` (the divergence-resolution test), `single_member_extension_is_not_a_mixin_group` (the fan-out honesty test).
15+
16+
**Why this matters for the spine answer.** It sharpens the Rails-as-calcification-anchor doctrine: Rails defines AR; Odoo gets graded against it. The mixin case looked like an Odoo divergence — but mapped onto the family-node substrate, it's a clean cross-curator match. The lesson: an apparent "Odoo non-AR divergence" should first be checked against the lance-graph substrate primitives (#545..#551 `members`/`memberof`, the family-node tree) before being called a divergence. The substrate already had the home for it. The mixin group node also carries the shared behaviour down to members — which is `E-FAMILY-NODE-IS-META-AWARENESS` exactly (the parent node IS the coarse summary its members inherit), now instantiated for ERP mixins rather than Walsh bands.
17+
18+
**Cross-refs:** `crates/lance-graph-ontology/src/ar_shape.rs` (`mixin_members` + `shared_mixin_groups` + 4 tests); `E-BASIN-IS-A-NODE` + `E-FAMILY-NODE-IS-META-AWARENESS` + `E-GUID-SELF-ROUTES-THE-BASIN-TREE` (the #545..#551 family-node arc this maps onto); `graph::mailbox_scan::{members, memberof}` (lance-graph #549, the substrate primitive); `E-OGAR-AR-SHAPE-SMOKE-5` (the concept-edge AST test this extends — mixin membership is the inheritance-edge complement to the composition-edge concept graph); `AdaWorldAPI/OGAR` CODEBOOK (`project_actor` STI-collapse used the same shape on User/Principal).
19+
20+
---
21+
122
## 2026-06-19 — E-OGAR-AR-SHAPE-SMOKE-5 — the ERP AST test passes at EDGE level, not just node level: OSB (Rails) and Odoo emit the SAME canonical concept-GRAPH (`CommercialLineItem → CommercialDocument`, `→ TaxPolicy`; `CommercialDocument → BillingParty`) despite different class names, field names, AND predicate vocabularies
223

324
**Status:** FINDING (operator-directed "odoo vs rails based ERP AST test", 2026-06-19; 31/31 ar_shape tests green, ar_shape clippy-clean). The synergy work so far proved NAME-level convergence (a class IRI → a `CanonicalConcept`). This entry lands the deeper, real test: **EDGE-level convergence** — the canonical concept GRAPH (concept→concept relations) is the same across a Rails-based ERP and Odoo, which is what makes it an *AST*, not a label table.

crates/lance-graph-ontology/src/ar_shape.rs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,103 @@ pub fn concept_edges(
673673
out
674674
}
675675

676+
// ─── Mixin = family/group node (members/memberof) ──────────────────────
677+
//
678+
// Operator nudge (2026-06-19): the family nodes introduced in lance-graph
679+
// #545..#551 (`graph::mailbox_scan::{members, memberof, BasinOf}`) can
680+
// serve as the MIXIN node. `group.members` = the classes that include the
681+
// mixin; `class.memberof` = the mixin group. A mixin IS a family/group
682+
// node; `include` (Rails) / `_inherit` (Odoo) IS the `memberof` edge.
683+
//
684+
// This RESOLVES a divergence flagged earlier this session. The earlier
685+
// claim that "Odoo `_inherit` is a non-AR shape with no Rails analog" was
686+
// WRONG: the Rails analog is `include` (concerns), and BOTH lower to the
687+
// same family-node `members`/`memberof` primitive. The harvest confirms
688+
// it — OSB carries 37 `includes_module` triples; Odoo carries 166
689+
// `inherits_from` triples; both are "this class is a member of group X".
690+
//
691+
// The ≥2-member filter IS the family-node fan-out: a group with multiple
692+
// members is a genuine mixin (shared behaviour); a single-member "group"
693+
// is an STI base / model extension, not a mixin. This is the same
694+
// distinction `members(basin)` draws structurally (a basin node fans out
695+
// to ≥1 child; a mixin group fans out to ≥2 members).
696+
697+
/// Extract the mixin-membership graph from a curator's harvest as a
698+
/// `group → {member classes}` map — the `members` direction of the
699+
/// family-node primitive. The inverse (`memberof`: a class → its mixin
700+
/// groups) is derivable by transposing.
701+
///
702+
/// `is_rails` selects the predicate: `true` reads `includes_module`
703+
/// (Rails `include ModuleX` concerns); `false` reads `inherits_from`
704+
/// (Odoo `_inherit` mixin chains). Object IRIs are namespace-stripped so
705+
/// the group name is the curator's bare module/mixin name
706+
/// (`PublicActivity::Model`, `mail_thread`).
707+
///
708+
/// **The group node IS a family node** (per `E-BASIN-IS-A-NODE` /
709+
/// `E-FAMILY-NODE-IS-META-AWARENESS`): the mixin's shared fields/methods
710+
/// live on the parent, and members inherit by being in its `members`
711+
/// set. No new substrate — `members`/`memberof` from #549 is the home.
712+
#[must_use]
713+
pub fn mixin_members(
714+
triples: &[Triple],
715+
namespace_prefix: &str,
716+
is_rails: bool,
717+
) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
718+
let predicate = if is_rails {
719+
"includes_module"
720+
} else {
721+
"inherits_from"
722+
};
723+
724+
let mut groups: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
725+
std::collections::BTreeMap::new();
726+
727+
for t in triples {
728+
if t.p != predicate {
729+
continue;
730+
}
731+
// Subject = the member class (ns-stripped, must be a class IRI).
732+
let Some(member) = t.s.strip_prefix(namespace_prefix) else {
733+
continue;
734+
};
735+
if member.contains('.') {
736+
continue; // a `Class.method` IRI, not a class
737+
}
738+
// Object = the mixin/group name. Rails objects carry no namespace
739+
// (bare `PublicActivity::Model`); Odoo objects are ns-prefixed
740+
// (`odoo:mail_thread`) — strip if present.
741+
let group = t.o.strip_prefix(namespace_prefix).unwrap_or(&t.o);
742+
if group.is_empty() {
743+
continue;
744+
}
745+
groups
746+
.entry(group.to_string())
747+
.or_default()
748+
.insert(member.to_string());
749+
}
750+
751+
groups
752+
}
753+
754+
/// Filter a `mixin_members` map to the **genuine mixin groups** — those
755+
/// with `≥ min_members` members. This is the family-node fan-out
756+
/// criterion: a group shared by ≥2 classes is a mixin (shared
757+
/// behaviour); a single-member "group" is an STI base / model extension,
758+
/// not a mixin. Default threshold per the doctrine is 2.
759+
///
760+
/// Returns the group names (sorted) that qualify.
761+
#[must_use]
762+
pub fn shared_mixin_groups(
763+
members: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
764+
min_members: usize,
765+
) -> Vec<String> {
766+
members
767+
.iter()
768+
.filter(|(_, m)| m.len() >= min_members)
769+
.map(|(g, _)| g.clone())
770+
.collect()
771+
}
772+
676773
// ─── Sibling concept detectors (lexical class-name shape on declared OGIT
677774
// ObjectTypes) ─────────────────────────────────────────────────────────
678775
//
@@ -1931,6 +2028,148 @@ mod tests {
19312028
);
19322029
}
19332030

2031+
// ─── Mixin = family/group node tests ────────────────────────────
2032+
2033+
/// **The operator nudge, mechanized.** A mixin IS a family/group
2034+
/// node: `group.members` = classes that include it. On the real
2035+
/// Odoo harvest, `mail_thread` is a group node whose `members` set
2036+
/// contains many classes (`account_move`, `account_account`,
2037+
/// `account_journal`, …) — exactly the `members(basin)` fan-out
2038+
/// from lance-graph #549. The ≥2-member filter picks it out as a
2039+
/// genuine mixin.
2040+
#[test]
2041+
fn odoo_mail_thread_is_a_family_group_node_with_many_members() {
2042+
let odoo = load_triples_ndjson(include_bytes!(
2043+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
2044+
))
2045+
.unwrap();
2046+
2047+
let members = mixin_members(&odoo, "odoo:", false);
2048+
2049+
// mail_thread is a group node; its members include sale_order +
2050+
// account_account (account_move itself rides mail_activity_mixin
2051+
// + sequence_mixin, not mail_thread directly — the harvest's
2052+
// distinction, faithfully preserved).
2053+
let mail_thread = members
2054+
.get("mail_thread")
2055+
.expect("mail_thread should be a mixin group");
2056+
assert!(
2057+
mail_thread.contains("sale_order") && mail_thread.contains("account_account"),
2058+
"mail_thread.members should contain sale_order + account_account; \
2059+
got {mail_thread:?}",
2060+
);
2061+
assert!(
2062+
mail_thread.len() >= 2,
2063+
"mail_thread is a SHARED mixin (≥2 members); got {}",
2064+
mail_thread.len(),
2065+
);
2066+
2067+
// It qualifies as a shared mixin group under the fan-out filter.
2068+
let shared = shared_mixin_groups(&members, 2);
2069+
assert!(
2070+
shared.iter().any(|g| g == "mail_thread"),
2071+
"mail_thread should pass the ≥2-member fan-out filter; \
2072+
shared groups: {:?}",
2073+
shared.iter().take(8).collect::<Vec<_>>(),
2074+
);
2075+
}
2076+
2077+
/// The Rails side carries the SAME `members`/`memberof` shape via
2078+
/// `includes_module`. OSB's `PublicActivity::Model` is a group node
2079+
/// whose members include multiple billing classes (`Client`,
2080+
/// `Estimate`, …) — proving the family-node mixin primitive is
2081+
/// curator-independent.
2082+
#[test]
2083+
fn osb_rails_public_activity_model_is_a_family_group_node() {
2084+
let osb = load_triples_ndjson(include_bytes!(
2085+
"../tests/fixtures/osb_ruby_spo.ndjson"
2086+
))
2087+
.unwrap();
2088+
2089+
let members = mixin_members(&osb, "openproject:", true);
2090+
2091+
let activity = members
2092+
.get("PublicActivity::Model")
2093+
.expect("PublicActivity::Model should be a Rails mixin group");
2094+
assert!(
2095+
activity.contains("Client") && activity.contains("Estimate"),
2096+
"PublicActivity::Model.members should include Client + Estimate; \
2097+
got {activity:?}",
2098+
);
2099+
assert!(activity.len() >= 2);
2100+
2101+
let shared = shared_mixin_groups(&members, 2);
2102+
assert!(shared.iter().any(|g| g == "PublicActivity::Model"));
2103+
}
2104+
2105+
/// **The divergence resolved.** Earlier this session I claimed Odoo
2106+
/// `_inherit` was a "non-AR shape with no Rails analog." This test
2107+
/// proves the opposite: BOTH curators expose the mixin-as-family-node
2108+
/// shape (a group with ≥2 members), so Odoo `_inherit` and Rails
2109+
/// `include` are the SAME `members`/`memberof` primitive — Odoo is
2110+
/// AR-shape-verified on mixins, not divergent.
2111+
#[test]
2112+
fn rails_include_and_odoo_inherit_are_the_same_family_node_primitive() {
2113+
let osb = load_triples_ndjson(include_bytes!(
2114+
"../tests/fixtures/osb_ruby_spo.ndjson"
2115+
))
2116+
.unwrap();
2117+
let odoo = load_triples_ndjson(include_bytes!(
2118+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
2119+
))
2120+
.unwrap();
2121+
2122+
let osb_shared = shared_mixin_groups(&mixin_members(&osb, "openproject:", true), 2);
2123+
let odoo_shared = shared_mixin_groups(&mixin_members(&odoo, "odoo:", false), 2);
2124+
2125+
// BOTH curators have ≥1 shared mixin group (the family-node
2126+
// fan-out shape) — the primitive is present on both sides.
2127+
assert!(
2128+
!osb_shared.is_empty(),
2129+
"OSB (Rails) must expose ≥1 shared mixin group via include",
2130+
);
2131+
assert!(
2132+
!odoo_shared.is_empty(),
2133+
"Odoo must expose ≥1 shared mixin group via _inherit",
2134+
);
2135+
// Both surface an activity-tracking mixin (the cross-curator
2136+
// semantic convergence): OSB PublicActivity::Model ≈ Odoo
2137+
// mail_activity_mixin / mail_thread.
2138+
assert!(osb_shared.iter().any(|g| g == "PublicActivity::Model"));
2139+
assert!(
2140+
odoo_shared
2141+
.iter()
2142+
.any(|g| g == "mail_activity_mixin" || g == "mail_thread"),
2143+
);
2144+
}
2145+
2146+
/// Single-member groups (STI bases / model extensions) are NOT
2147+
/// mixins — the ≥2 fan-out filter excludes them. In Odoo,
2148+
/// `account_bank_statement_line inherits_from account_move` is a
2149+
/// model EXTENSION (one member), not a mixin; `account_move` must
2150+
/// not appear as a shared mixin group on the strength of that one
2151+
/// edge alone.
2152+
#[test]
2153+
fn single_member_extension_is_not_a_mixin_group() {
2154+
let odoo = load_triples_ndjson(include_bytes!(
2155+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
2156+
))
2157+
.unwrap();
2158+
let members = mixin_members(&odoo, "odoo:", false);
2159+
// account_move may be inherited by exactly one statement-line
2160+
// class — if so it's an extension, not a shared mixin. Assert
2161+
// the filter is honest: a group is only "shared" at ≥2.
2162+
if let Some(am_members) = members.get("account_move") {
2163+
if am_members.len() < 2 {
2164+
let shared = shared_mixin_groups(&members, 2);
2165+
assert!(
2166+
!shared.iter().any(|g| g == "account_move"),
2167+
"account_move with <2 members must NOT be a shared mixin",
2168+
);
2169+
}
2170+
}
2171+
}
2172+
19342173
// ─── One-shot synergy registry test ─────────────────────────────
19352174

19362175
/// `synergy_registry_one_shot` consumes all three workspace

0 commit comments

Comments
 (0)