Skip to content

Commit 85e3279

Browse files
committed
feat(ar_shape): Spree harvest + 5 commerce concepts (SalesOrder / SalesOrderLine / FulfillmentFlow / InventoryMovement / ProductOffering); 3-curator convergence on TaxPolicy + PaymentRecord
Phase 2 of operator's "all of the above" 2026-06-19. Smoke target B (Spree↔Odoo commerce overlap) landed with real harvested triples. What landed: 1. Spree harvest fixture — pulled spree/spree upstream zipball (85 MB, no AdaWorldAPI fork available; canon's forks-only rule applies to Cargo deps, not read-only fixture harvests). Ran ruff_ruby_spo on spree/core: 289 models, 7954 triples, 909 KB ndjson → checked in as crates/lance-graph-ontology/tests/fixtures/spree_ruby_spo.ndjson. 2. CanonicalConcept enum 6 → 11 variants. New (all from smoke target B): - SalesOrder (Spree::Order + odoo:sale_order) - SalesOrderLine (Spree::LineItem + odoo:sale_order_line) - FulfillmentFlow (Spree::Shipment + odoo:stock_picking) - InventoryMovement (Spree::InventoryUnit + odoo:stock_move) - ProductOffering (Spree::Product / Variant + odoo:product_product / product_template) 3. CommercialDocument detector narrowed: drops the "order" ending so sale_order / Spree::Order route to SalesOrder (a distinct commerce-side concept), keeping CommercialDocument as the accounting-document concept (Invoice / account_move). This is the distinct-but-adjacent split the operator's smoke spec implied. 4. 5 new sibling detectors with the same lexical-shape pattern as the commerce sextet, each with its concept-specific lexical hint: - classes_matching_sales_order_shape_canonical (ends_with "order" AND NOT contains "line") - classes_matching_sales_order_line_shape_canonical (ends_with "lineitem" OR ends_with "order_line"/"orderline") - classes_matching_fulfillment_flow_shape_canonical (ends_with "shipment" OR ends_with "picking") - classes_matching_inventory_movement_shape_canonical (ends_with "inventoryunit" OR ends_with "stock_move" — the stock_ qualifier discriminates from account_move which is a CommercialDocument) - classes_matching_product_offering_shape_canonical (ends_with "product" OR ends_with "variant" OR ends_with "product_template") 5. TaxPolicy detector hardened: also matches contains("taxrate") so Spree::TaxRate surfaces (the strongest commerce-side tax-policy signal). OSB::Tax and odoo:account_tax stay matched via the existing ends_with("tax") arm. 6. 6 new corpus-driven tests, all green: - spree_order_and_odoo_sale_order_overlap_as_sales_order - spree_line_item_and_odoo_sale_order_line_overlap_as_sales_order_line - spree_shipment_and_odoo_stock_picking_overlap_as_fulfillment_flow - spree_inventory_unit_and_odoo_stock_move_overlap_as_inventory_movement (also asserts account_move does NOT promote here) - spree_product_variant_and_odoo_product_overlap_as_product_offering - spree_third_curator_convergence_on_tax_policy_and_payment_record (proves the existing TaxPolicy + PaymentRecord detectors generalize to a 3rd curator — Spree::TaxRate, Spree::Payment) Plus all 20 prior tests still green → 26/26 total. Scope reminder per operator discipline acceptance #4-7: each promotion is gated by ≥2-curator structural evidence on real corpora, not pre-emptive. The SMOKE-1 to SMOKE-4 + this commit's 5 concepts now cover smoke targets A (OSB↔Odoo accounting) and B (Spree↔Odoo commerce). Smoke target C (Redmine/OpenProject Project::TimeEntry materialization) is project-domain — already being shipped by the other session into OGAR's CODEBOOK (PRs #61/#62/#63 promoted project, project_work_item, billable_work_entry, project_actor, project_status, project_type). CanonicalConcept enum now 11 variants. The lance-graph-ontology CanonicalConcept set complements OGAR's project-domain 6-entry CODEBOOK with an 11-entry commerce/billing/erp candidate set (CommercialLineItem, CommercialDocument, TaxPolicy, BillingParty, PaymentRecord, CurrencyPolicy, SalesOrder, SalesOrderLine, FulfillmentFlow, InventoryMovement, ProductOffering). Each candidate is a future OGAR CODEBOOK row (Phase 3 of "all of the above" — the OGAR upstream promotion PR). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xzyc27Nx3f8WC5KzwfWfjx
1 parent 29336f4 commit 85e3279

2 files changed

Lines changed: 8293 additions & 11 deletions

File tree

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

Lines changed: 339 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,27 @@ pub enum CanonicalConcept {
139139
/// and a label. Promoted from `{ osb:Currency, odoo:res.currency }`
140140
/// on 2026-06-19.
141141
CurrencyPolicy,
142+
/// `SalesOrder` — a customer-facing order document (commerce-side
143+
/// sibling of `CommercialDocument`). Promoted from
144+
/// `{ spree:Spree::Order, odoo:sale.order }` on 2026-06-19.
145+
SalesOrder,
146+
/// `SalesOrderLine` — a per-line entry on a sales order
147+
/// (commerce-side sibling of `CommercialLineItem`). Promoted from
148+
/// `{ spree:Spree::LineItem, odoo:sale.order.line }` on 2026-06-19.
149+
SalesOrderLine,
150+
/// `FulfillmentFlow` — the shipment/picking flow that moves
151+
/// inventory to fulfill a sales order. Promoted from
152+
/// `{ spree:Spree::Shipment, odoo:stock.picking }` on 2026-06-19.
153+
FulfillmentFlow,
154+
/// `InventoryMovement` — a single inventory state change
155+
/// (allocation, reservation, transfer). Promoted from
156+
/// `{ spree:Spree::InventoryUnit, odoo:stock.move }` on
157+
/// 2026-06-19.
158+
InventoryMovement,
159+
/// `ProductOffering` — the catalog product / variant that gets
160+
/// sold. Promoted from `{ spree:Spree::Product, odoo:product.product }`
161+
/// on 2026-06-19.
162+
ProductOffering,
142163
}
143164

144165
/// A typed fixture for one curator's class declaration. Hand-built today;
@@ -587,11 +608,17 @@ pub fn classes_participating_in_canonical_relations(
587608
}
588609

589610
/// Find class IRIs in a triple set shaped like a `CommercialDocument`
590-
/// (the parent of line items): class-IRI's lowercased form ends with
591-
/// `"invoice"` (`osb:Invoice`), `"move"` (`odoo:account_move`), or
592-
/// `"order"` (`odoo:sale_order`), and the IRI does NOT contain `"line"`
593-
/// (to filter out `InvoiceLineItem` / `account_move_line` which are
611+
/// (the accounting parent of line items): class-IRI's lowercased form
612+
/// ends with `"invoice"` (`osb:Invoice`) or `"move"`
613+
/// (`odoo:account_move`), and the IRI does NOT contain `"line"` (to
614+
/// filter out `InvoiceLineItem` / `account_move_line` which are
594615
/// CommercialLineItem candidates, not document candidates).
616+
///
617+
/// Note: `"order"` endings are NOT matched here — those land as
618+
/// [`classes_matching_sales_order_shape_canonical`] (commerce side).
619+
/// `Invoice`/`account_move` is the accounting document; `Order`/
620+
/// `sale_order` is the commerce document; the two are
621+
/// distinct-but-adjacent concepts.
595622
#[must_use]
596623
pub fn classes_matching_commercial_document_shape_canonical(
597624
triples: &[Triple],
@@ -604,25 +631,134 @@ pub fn classes_matching_commercial_document_shape_canonical(
604631
if lower.contains("line") {
605632
return false;
606633
}
607-
lower.ends_with("invoice")
608-
|| lower.ends_with("move")
609-
|| lower.ends_with("order")
634+
lower.ends_with("invoice") || lower.ends_with("move")
635+
})
636+
.collect()
637+
}
638+
639+
/// Find class IRIs shaped like a `SalesOrder` (commerce-side sibling of
640+
/// `CommercialDocument`): lowercased ends with `"order"` and the IRI
641+
/// does NOT contain `"line"` (to filter out `Spree::LineItem` /
642+
/// `sale_order_line` — those are `SalesOrderLine`). Catches
643+
/// `Spree::Order` and `odoo:sale_order`.
644+
#[must_use]
645+
pub fn classes_matching_sales_order_shape_canonical(
646+
triples: &[Triple],
647+
namespace_prefix: &str,
648+
) -> Vec<String> {
649+
declared_classes(triples, namespace_prefix)
650+
.into_iter()
651+
.filter(|c| {
652+
let lower = c.to_lowercase();
653+
if lower.contains("line") {
654+
return false;
655+
}
656+
lower.ends_with("order")
657+
})
658+
.collect()
659+
}
660+
661+
/// Find class IRIs shaped like a `SalesOrderLine`: lowercased ends with
662+
/// `"lineitem"` (Spree `LineItem` → snake = "line_item"; `to_lowercase`
663+
/// just removes case so `LineItem` → `"lineitem"`) OR ends with
664+
/// `"order_line"` (`odoo:sale_order_line`). Catches `Spree::LineItem`
665+
/// and `odoo:sale_order_line`. Does NOT match `InvoiceLineItem` (that's
666+
/// `CommercialLineItem`); the `"order"` suffix on the snake-cased Odoo
667+
/// IRI discriminates.
668+
#[must_use]
669+
pub fn classes_matching_sales_order_line_shape_canonical(
670+
triples: &[Triple],
671+
namespace_prefix: &str,
672+
) -> Vec<String> {
673+
declared_classes(triples, namespace_prefix)
674+
.into_iter()
675+
.filter(|c| {
676+
let lower = c.to_lowercase();
677+
// Spree's `LineItem` directly under `Spree::` ends in
678+
// "lineitem"; siblings like `Spree::OrderLineItem` etc.
679+
// also legitimately match.
680+
if lower.ends_with("lineitem") {
681+
return true;
682+
}
683+
// Odoo's `sale_order_line` ends with "order_line"; matches
684+
// also `sale_order_line_template` if it exists.
685+
lower.ends_with("order_line") || lower.ends_with("orderline")
686+
})
687+
.collect()
688+
}
689+
690+
/// Find class IRIs shaped like a `FulfillmentFlow`: lowercased ends with
691+
/// `"shipment"` (`Spree::Shipment`) or `"picking"` (`odoo:stock_picking`).
692+
#[must_use]
693+
pub fn classes_matching_fulfillment_flow_shape_canonical(
694+
triples: &[Triple],
695+
namespace_prefix: &str,
696+
) -> Vec<String> {
697+
declared_classes(triples, namespace_prefix)
698+
.into_iter()
699+
.filter(|c| {
700+
let lower = c.to_lowercase();
701+
lower.ends_with("shipment") || lower.ends_with("picking")
702+
})
703+
.collect()
704+
}
705+
706+
/// Find class IRIs shaped like an `InventoryMovement`: lowercased ends
707+
/// with `"inventoryunit"` (`Spree::InventoryUnit`) or `"stock_move"`
708+
/// (`odoo:stock_move`). Filters out `account_move` (which is a
709+
/// CommercialDocument) by requiring the `"stock_"` qualifier on the
710+
/// Odoo side.
711+
#[must_use]
712+
pub fn classes_matching_inventory_movement_shape_canonical(
713+
triples: &[Triple],
714+
namespace_prefix: &str,
715+
) -> Vec<String> {
716+
declared_classes(triples, namespace_prefix)
717+
.into_iter()
718+
.filter(|c| {
719+
let lower = c.to_lowercase();
720+
lower.ends_with("inventoryunit") || lower.ends_with("stock_move")
721+
})
722+
.collect()
723+
}
724+
725+
/// Find class IRIs shaped like a `ProductOffering`: lowercased ends with
726+
/// `"product"` (`Spree::Product`, `odoo:product_product`) or
727+
/// `"variant"` (`Spree::Variant`) or `"product_template"`
728+
/// (`odoo:product_template`).
729+
#[must_use]
730+
pub fn classes_matching_product_offering_shape_canonical(
731+
triples: &[Triple],
732+
namespace_prefix: &str,
733+
) -> Vec<String> {
734+
declared_classes(triples, namespace_prefix)
735+
.into_iter()
736+
.filter(|c| {
737+
let lower = c.to_lowercase();
738+
lower.ends_with("product")
739+
|| lower.ends_with("variant")
740+
|| lower.ends_with("product_template")
610741
})
611742
.collect()
612743
}
613744

614745
/// Find class IRIs shaped like a `TaxPolicy`: class IRI's lowercased
615-
/// form ends with `"tax"`. Catches `osb:Tax` and `odoo:account_tax`;
616-
/// excludes `TaxGroup` / `account_tax_group` (lowercased `"taxgroup"`,
617-
/// `"account_tax_group"` — neither ends with `"tax"`).
746+
/// form ends with `"tax"` (`osb:Tax`, `odoo:account_tax`,
747+
/// `Spree::Calculator::DefaultTax`) OR contains `"taxrate"`
748+
/// (`Spree::TaxRate` — the strongest commerce-side tax-policy
749+
/// signal). Excludes `TaxGroup` / `account_tax_group` (lowercased
750+
/// `"taxgroup"` / `"account_tax_group"` — neither tail matches).
618751
#[must_use]
619752
pub fn classes_matching_tax_policy_shape_canonical(
620753
triples: &[Triple],
621754
namespace_prefix: &str,
622755
) -> Vec<String> {
623756
declared_classes(triples, namespace_prefix)
624757
.into_iter()
625-
.filter(|c| c.to_lowercase().ends_with("tax"))
758+
.filter(|c| {
759+
let lower = c.to_lowercase();
760+
lower.ends_with("tax") || lower.contains("taxrate")
761+
})
626762
.collect()
627763
}
628764

@@ -1334,6 +1470,198 @@ mod tests {
13341470
);
13351471
}
13361472

1473+
// ─── Spree harvest tests (smoke target B; 3rd curator) ────────
1474+
1475+
/// `spree_order_and_odoo_sale_order_overlap_as_sales_order`
1476+
/// — the headline smoke target B per operator directive.
1477+
#[test]
1478+
fn spree_order_and_odoo_sale_order_overlap_as_sales_order() {
1479+
let spree_bytes =
1480+
include_bytes!("../tests/fixtures/spree_ruby_spo.ndjson");
1481+
let odoo_bytes = include_bytes!(
1482+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
1483+
);
1484+
let spree = load_triples_ndjson(spree_bytes).unwrap();
1485+
let odoo = load_triples_ndjson(odoo_bytes).unwrap();
1486+
1487+
let spree_c = classes_matching_sales_order_shape_canonical(&spree, "openproject:");
1488+
let odoo_c = classes_matching_sales_order_shape_canonical(&odoo, "odoo:");
1489+
1490+
assert!(
1491+
spree_c.iter().any(|c| c == "Spree::Order"),
1492+
"Spree candidates missing Spree::Order; got first 5: {:?}",
1493+
spree_c.iter().take(5).collect::<Vec<_>>(),
1494+
);
1495+
assert!(
1496+
odoo_c.iter().any(|c| c == "sale_order"),
1497+
"Odoo candidates missing sale_order; got first 5: {:?}",
1498+
odoo_c.iter().take(5).collect::<Vec<_>>(),
1499+
);
1500+
// Spree::Order must NOT promote as CommercialDocument — sales
1501+
// orders are commerce-side, distinct from accounting docs.
1502+
let spree_cd =
1503+
classes_matching_commercial_document_shape_canonical(&spree, "openproject:");
1504+
assert!(!spree_cd.iter().any(|c| c == "Spree::Order"));
1505+
let odoo_cd =
1506+
classes_matching_commercial_document_shape_canonical(&odoo, "odoo:");
1507+
assert!(!odoo_cd.iter().any(|c| c == "sale_order"));
1508+
}
1509+
1510+
/// `spree_line_item_and_odoo_sale_order_line_overlap_as_sales_order_line`
1511+
/// — operator-named test from smoke target B.
1512+
#[test]
1513+
fn spree_line_item_and_odoo_sale_order_line_overlap_as_sales_order_line() {
1514+
let spree_bytes =
1515+
include_bytes!("../tests/fixtures/spree_ruby_spo.ndjson");
1516+
let odoo_bytes = include_bytes!(
1517+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
1518+
);
1519+
let spree = load_triples_ndjson(spree_bytes).unwrap();
1520+
let odoo = load_triples_ndjson(odoo_bytes).unwrap();
1521+
1522+
let spree_c =
1523+
classes_matching_sales_order_line_shape_canonical(&spree, "openproject:");
1524+
let odoo_c =
1525+
classes_matching_sales_order_line_shape_canonical(&odoo, "odoo:");
1526+
1527+
assert!(
1528+
spree_c.iter().any(|c| c == "Spree::LineItem"),
1529+
"Spree candidates missing Spree::LineItem; got first 5: {:?}",
1530+
spree_c.iter().take(5).collect::<Vec<_>>(),
1531+
);
1532+
assert!(
1533+
odoo_c.iter().any(|c| c == "sale_order_line"),
1534+
"Odoo candidates missing sale_order_line; got first 5: {:?}",
1535+
odoo_c.iter().take(5).collect::<Vec<_>>(),
1536+
);
1537+
}
1538+
1539+
/// `spree_shipment_and_odoo_stock_picking_overlap_as_fulfillment_flow`.
1540+
#[test]
1541+
fn spree_shipment_and_odoo_stock_picking_overlap_as_fulfillment_flow() {
1542+
let spree_bytes =
1543+
include_bytes!("../tests/fixtures/spree_ruby_spo.ndjson");
1544+
let odoo_bytes = include_bytes!(
1545+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
1546+
);
1547+
let spree = load_triples_ndjson(spree_bytes).unwrap();
1548+
let odoo = load_triples_ndjson(odoo_bytes).unwrap();
1549+
1550+
let spree_c =
1551+
classes_matching_fulfillment_flow_shape_canonical(&spree, "openproject:");
1552+
let odoo_c =
1553+
classes_matching_fulfillment_flow_shape_canonical(&odoo, "odoo:");
1554+
1555+
assert!(
1556+
spree_c.iter().any(|c| c == "Spree::Shipment"),
1557+
"Spree candidates missing Spree::Shipment; got {spree_c:?}",
1558+
);
1559+
assert!(
1560+
odoo_c.iter().any(|c| c == "stock_picking"),
1561+
"Odoo candidates missing stock_picking; got {odoo_c:?}",
1562+
);
1563+
}
1564+
1565+
/// `spree_inventory_unit_and_odoo_stock_move_overlap_as_inventory_movement`.
1566+
/// Critically: must NOT match `account_move` (CommercialDocument)
1567+
/// — the `stock_` qualifier discriminates.
1568+
#[test]
1569+
fn spree_inventory_unit_and_odoo_stock_move_overlap_as_inventory_movement() {
1570+
let spree_bytes =
1571+
include_bytes!("../tests/fixtures/spree_ruby_spo.ndjson");
1572+
let odoo_bytes = include_bytes!(
1573+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
1574+
);
1575+
let spree = load_triples_ndjson(spree_bytes).unwrap();
1576+
let odoo = load_triples_ndjson(odoo_bytes).unwrap();
1577+
1578+
let spree_c =
1579+
classes_matching_inventory_movement_shape_canonical(&spree, "openproject:");
1580+
let odoo_c =
1581+
classes_matching_inventory_movement_shape_canonical(&odoo, "odoo:");
1582+
1583+
assert!(
1584+
spree_c.iter().any(|c| c == "Spree::InventoryUnit"),
1585+
"Spree candidates missing Spree::InventoryUnit; got {spree_c:?}",
1586+
);
1587+
assert!(
1588+
odoo_c.iter().any(|c| c == "stock_move"),
1589+
"Odoo candidates missing stock_move; got {odoo_c:?}",
1590+
);
1591+
// account_move must NOT promote as InventoryMovement — the
1592+
// stock_ qualifier is what discriminates.
1593+
assert!(!odoo_c.iter().any(|c| c == "account_move"));
1594+
}
1595+
1596+
/// `spree_product_variant_and_odoo_product_overlap_as_product_offering`.
1597+
#[test]
1598+
fn spree_product_variant_and_odoo_product_overlap_as_product_offering() {
1599+
let spree_bytes =
1600+
include_bytes!("../tests/fixtures/spree_ruby_spo.ndjson");
1601+
let odoo_bytes = include_bytes!(
1602+
"../../lance-graph/src/graph/spo/odoo_ontology.spo.ndjson"
1603+
);
1604+
let spree = load_triples_ndjson(spree_bytes).unwrap();
1605+
let odoo = load_triples_ndjson(odoo_bytes).unwrap();
1606+
1607+
let spree_c =
1608+
classes_matching_product_offering_shape_canonical(&spree, "openproject:");
1609+
let odoo_c =
1610+
classes_matching_product_offering_shape_canonical(&odoo, "odoo:");
1611+
1612+
assert!(
1613+
spree_c.iter().any(|c| c == "Spree::Product"),
1614+
"Spree candidates missing Spree::Product; got first 5: {:?}",
1615+
spree_c.iter().take(5).collect::<Vec<_>>(),
1616+
);
1617+
assert!(
1618+
spree_c.iter().any(|c| c == "Spree::Variant"),
1619+
"Spree candidates missing Spree::Variant; got first 5: {:?}",
1620+
spree_c.iter().take(5).collect::<Vec<_>>(),
1621+
);
1622+
assert!(
1623+
odoo_c.iter().any(|c| c == "product_product"),
1624+
"Odoo candidates missing product_product; got first 5: {:?}",
1625+
odoo_c.iter().take(5).collect::<Vec<_>>(),
1626+
);
1627+
assert!(
1628+
odoo_c.iter().any(|c| c == "product_template"),
1629+
"Odoo candidates missing product_template; got first 5: {:?}",
1630+
odoo_c.iter().take(5).collect::<Vec<_>>(),
1631+
);
1632+
}
1633+
1634+
/// 3-curator convergence on the existing OSB↔Odoo concepts when
1635+
/// Spree is added as a 3rd curator: TaxPolicy + PaymentRecord
1636+
/// surface on Spree too (`Spree::TaxRate`, `Spree::Payment`),
1637+
/// proving the existing detectors generalize beyond the 2-curator
1638+
/// gate.
1639+
#[test]
1640+
fn spree_third_curator_convergence_on_tax_policy_and_payment_record() {
1641+
let spree_bytes =
1642+
include_bytes!("../tests/fixtures/spree_ruby_spo.ndjson");
1643+
let spree = load_triples_ndjson(spree_bytes).unwrap();
1644+
1645+
let tax = classes_matching_tax_policy_shape_canonical(&spree, "openproject:");
1646+
let payment =
1647+
classes_matching_payment_record_shape_canonical(&spree, "openproject:");
1648+
1649+
// Spree models multiple tax classes — TaxRate, TaxCategory,
1650+
// Calculator::DefaultTax, Adjustable::Adjuster::Tax. Any
1651+
// ending in "tax" counts. TaxRate is the strongest match
1652+
// (corresponds to OSB::Tax / odoo:account_tax).
1653+
assert!(
1654+
tax.iter().any(|c| c.ends_with("TaxRate") || c == "Spree::TaxRate"),
1655+
"Spree candidates missing a TaxRate; got first 5: {:?}",
1656+
tax.iter().take(5).collect::<Vec<_>>(),
1657+
);
1658+
assert!(
1659+
payment.iter().any(|c| c == "Spree::Payment"),
1660+
"Spree candidates missing Spree::Payment; got first 5: {:?}",
1661+
payment.iter().take(5).collect::<Vec<_>>(),
1662+
);
1663+
}
1664+
13371665
/// Structural-hardening seed: every concept-shape candidate the
13381666
/// six lexical detectors surface on the real OSB + Odoo corpora
13391667
/// must ALSO appear in the participating-classes set (i.e. surface

0 commit comments

Comments
 (0)