diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index eca85f627..864039ffe 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -18,11 +18,12 @@ Plan: `.claude/plans/gremlin-composable-algebra-v1.md`. | D-id | scope | status | gate / falsifier | |---|---|---|---| -| D-CMH-G1-0 | vocabulary → algebra matrix, TinkerPop 3.7.7 | In PR | every row cites a test, a refusal or the missing capability | -| D-CMH-G1-1 | predicate methods + method references | In PR | composed `programs()` == hand conjunction; cycles, contract, cross-class refused | -| D-CMH-G1-2 | typed Gremlin steps → method body | In PR | == direct Quack program; both frontends == row oracle | -| D-CMH-G1-3 | Gremlin-shaped runtime declarations | In PR | loads after the build; effects refused at load | -| D-CMH-G1-4 | relation carrier in the glove; anchor rule in `compose` | Queued | #1311 bulk oracle + DuckDB through the method layer | +| D-CMH-G1-0 | vocabulary → algebra matrix, TinkerPop 3.7.7 | Shipped (#1508) | every row cites a test, a refusal or the missing capability | +| D-CMH-G1-1 | predicate methods + method references | Shipped (#1508) | composed `programs()` == hand conjunction; cycles, contract, cross-class refused | +| D-CMH-G1-2 | typed Gremlin steps → method body | Shipped (#1508) | == direct Quack program; both frontends == row oracle | +| D-CMH-G1-3 | Gremlin-shaped runtime declarations | Shipped (#1508) | loads after the build; effects refused at load | +| D-CMH-G1-4a | functional relation carrier: `out(rel)`, `out(rel).has`, `count()` | In PR | row loop over the text at n ∈ {1,64,65,4097}; 6 disable runs red; R2IL refuses (no `GATHER`) | +| D-CMH-G1-4 | rest of the relation carrier (fan-out moves the anchor; `E()`, `outE/inV`) | Queued | #1311 bulk oracle + DuckDB through the method layer | #1311 bulk oracle + DuckDB through the method layer | | D-CMH-G1-5 | FirstOfRun | Queued | insufficiency proof first; Quack + R2IL vs independent oracle | ## D-NALP — `NAL::narsese()` polyfill over R2IL procedures and mask-risc (2026-10-12) diff --git a/.claude/board/entries/2026-10-12-functional-relation-carrier.md b/.claude/board/entries/2026-10-12-functional-relation-carrier.md new file mode 100644 index 000000000..c12ec28ea --- /dev/null +++ b/.claude/board/entries/2026-10-12-functional-relation-carrier.md @@ -0,0 +1,19 @@ +# 2026-10-12 — D-CMH-G1-4a: functional relation carrier in the method glove + +**Status:** In PR. **Plan:** `.claude/plans/gremlin-composable-algebra-v1.md` §8. + +A `Glove` carries `Relation`s (key field, target glove, foreign plane and lane +indices). `out(rel)` composes to `Cond::Reaches` → `Filter::Semijoin` over the +target's live plane; `out(rel).has(f, eq/neq)` adds `Filter::EqU32Via`. The +anchor stays, so `count()` after the hop counts anchor rows. No new op in +Quack, mask-risc or R2IL. + +MEASURED (tests/relation.rs, row loop over field text, n ∈ {1,64,65,4097}): +all answers equal; six disable runs red on the test that names each guard. +Removing the liveness test fails the row oracle, so `EqU32Via` alone is not +`out(rel)`: it keeps rows whose key names a dead target. + +OPEN: R2IL's fold band has `VIA` but no `GATHER`, so it cannot test that a key +names a live row; relation methods return `CallError::FrontendGap { R2il, +"GATHER" }`. Fan-out (`in`, M:N, `E()`) and a second hop stay refused; +D-SAG-1-4's multi-hop dependency is still blocked on the second hop. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index edd308029..5b445e0cb 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -316 entries, 2026-08-06 .. 2026-10-12. +317 entries, 2026-08-06 .. 2026-10-12. | date | entry id | finding | file | |---|---|---|---| @@ -33,6 +33,7 @@ exactly one of them, never both. | 2026-10-12 | `opencog-pln-entropy-research-map` | Source inventory and falsifiable PLN/ECAN/Shannon/Moore attention comparison: entropy target, evidence overlap, graph projection, resource fairness | [2026-10-12-opencog-pln-entropy-research-map.md](2026-10-12-opencog-pln-entropy-research-map.md) | | 2026-10-12 | `nars-self-evidence-literature` | NARS internal trace versus evidence, Jøsang dependent/independent fusion and fission, and the snapshot-stable fold assumption; source-grounded red tests for merged #1496 | [2026-10-12-nars-self-evidence-literature.md](2026-10-12-nars-self-evidence-literature.md) | | 2026-10-12 | `hyperon-metta-lg-translation` | Hyperon/MeTTa to LG sibling map: atom/hypergraph roundtrip, binding and rewrite parity, typed folds, scoped effects and 15 open questions | [2026-10-12-hyperon-metta-lg-translation.md](2026-10-12-hyperon-metta-lg-translation.md) | +| 2026-10-12 | `D-CMH-G1-4a` | | [2026-10-12-functional-relation-carrier.md](2026-10-12-functional-relation-carrier.md) | | 2026-10-12 | `D-CMH-0` | Class × Schema × Methods activation connects HotPlug, WideFieldMask, Quack and R2IL without a second registry | [2026-10-12-d-cmh-0-class-method-hotplug-activation.md](2026-10-12-d-cmh-0-class-method-hotplug-activation.md) | | 2026-10-12 | `D-CMH-1` | | [2026-10-12-class-method-polyfill-ledger.md](2026-10-12-class-method-polyfill-ledger.md) | | 2026-10-12 | `D-HL-0` | CE64 hypothesis layers: plateau of the design (opaque LE bytes, 64 sparse-delta layers merged on read, Narsese-parallel validation), 8 design-vs-code conflicts, 9 unification hypotheses | [2026-10-12-ce64-hypothesis-layers-plateau.md](2026-10-12-ce64-hypothesis-layers-plateau.md) | diff --git a/.claude/plans/gremlin-composable-algebra-v1.md b/.claude/plans/gremlin-composable-algebra-v1.md index 02894e09a..517031423 100644 --- a/.claude/plans/gremlin-composable-algebra-v1.md +++ b/.claude/plans/gremlin-composable-algebra-v1.md @@ -125,7 +125,7 @@ Rows group steps of one mechanism, so percentages are of rows, not of step names | hasKey/hasValue/hasNot | selection | V->V | keeps | null | pure | YET | nullable fields refused (`a_nullable_field_is_refused`) | three-valued condition + null policy | | is | selection | value->value | keeps | — | pure | COMPOSE | = has(f,p) when the value is an anchor field | value-cursor filter | | where(method()) | selection | V->V | keeps | — | pure | DIRECT | inlined predicate (tests/compose.rs) | — | -| where(traversal) functional | selection | V->V | keeps | — | pure | COMPOSE | #1311 `functional_where_equals_sql_join_sum` (EqU32Via) | relation carrier | +| where(traversal) functional | selection | V->V | keeps | — | pure | DIRECT (G1-4a) | a predicate method over `out(rel).has(..)`, referenced by `where(method())` (tests/relation.rs `a_predicate_through_the_relation_is_reused_by_reference`) | — | | where(traversal) fan-out | selection | V->V (set) | set over fk | ordered fk | pure | COMPOSE | #1311 `exists_over_children…` (CountDistinctOrdered; refuses unordered) | relation carrier + order witness | | and | boolean | pred,pred->pred | keeps | — | pure | DIRECT | conjunction of Conds | — | | or | boolean | pred,pred->pred | keeps | — | pure | COMPOSE | Filter::Or | Cond::Or | @@ -138,7 +138,7 @@ Rows group steps of one mechanism, so percentages are of rows, not of step names | range/limit/skip/tail | ordered | any | prefix of an order | order | pure | YET | Cmp::Range is row-ordinal only | ordered population op | | none | selection | any->∅ | — | — | pure | COMPOSE | empty filter | — | | all/any (list) | selection | list->list | — | — | pure | OUT | no list type | list values | -| out(rel) functional | traversal | V->V' | keeps (anchor stays) | — | pure | COMPOSE | #1311 functional hop: Semijoin + EqU32Via | relation carrier | +| out(rel) functional | traversal | V->V' | keeps (anchor stays) | — | pure | DIRECT (G1-4a) | `Cond::Reaches` (Semijoin over the target's live plane) + `Cond::Via` (EqU32Via); tests/relation.rs vs a row loop over the text; R2IL refuses by name (no `GATHER`) | R2IL `GATHER` | | in(rel) reverse fk | traversal | V->V' | re-anchor on child rows | — | pure | COMPOSE | #1311 `reverse_fanout_reanchors…` | relation carrier | | out/in(rel) M:N | traversal | V->V' | re-anchor on edge rows | — | pure | COMPOSE | #1311 `many_to_many_one_hop…` | relation carrier | | both(rel) | traversal | V->V' | union of two bags | — | pure | YET | no bag union across anchors | typed bag merge | @@ -191,12 +191,12 @@ Rows group steps of one mechanism, so percentages are of rows, not of step names | TextP.startingWith | COMPOSE | PrefixLowering over an ordered dictionary lane | | TextP.endingWith/containing/regex and negations | OUT | strings are codes | -Step rows: 67. DIRECT 10 (15%), COMPOSE 22 (33%), YET 19 (28%), EFFECT 4 (6%), OUT 12 (18%) +Step rows: 67. DIRECT 12 (18%), COMPOSE 20 (30%), YET 19 (28%), EFFECT 4 (6%), OUT 12 (18%) — after G1-4a; at #1508: DIRECT 10, COMPOSE 22 Predicate rows: 7. DIRECT 1, COMPOSE 4, YET 1, OUT 1 YET rows by missing capability: row answer type ×3; ordered population op ×2; typed bag merge ×2; ordered u32 compare (G5) ×1; three-valued condition + null policy ×1; ordered first-occurrence mask (FirstOfRun) ×1; composed fk read (G1) ×1; barrier / fan-in operand (G2) ×1; binding environment ×1; binding environment + join ×1; field-reading count fold ×1; masked_sum_i32_via ×1; typed branch + merge ×1; bounded fixpoint ×1; provenance-carrying path ×1 -COMPOSE rows by what the method layer lacks: relation carrier ×8; Cond::Or ×3; Cond ordered compare ×2; — ×2; id↔row binding in Glove ×1; value-cursor filter ×1; relation carrier + order witness ×1; Cond::Not ×1; Fold::Min/Max ×1; a law over two folds ×1; Fold::GroupCount ×1 +COMPOSE rows by what the method layer lacks: relation carrier ×6 (×8 at #1508; G1-4a moved functional `out` and `where` over it to DIRECT); Cond::Or ×3; Cond ordered compare ×2; — ×2; id↔row binding in Glove ×1; value-cursor filter ×1; relation carrier + order witness ×1; Cond::Not ×1; Fold::Min/Max ×1; a law over two folds ×1; Fold::GroupCount ×1 ## §5 — What the matrix says to build next @@ -233,13 +233,53 @@ traversal grants no authority. Not exercised here: no write of any kind. 4. `dedup` or `count` composed past `values(f)` without a refusal. 5. A declaration that loads an effect. +## §8 — D-CMH-G1-4a: the functional relation carrier + +A [`Glove`] now carries [`Relation`]s: the canonical key field on its concept, +the target concept's glove, and where a call supplies the target's live plane +and value lanes (`Foreign::planes` / `Foreign::lanes`). That is #1311's +adapter, minus the relations that fan out. + +- `out(rel)` composes to `Cond::Reaches { rel }` and binds to + `Filter::Semijoin(fk, target live plane)`. Gremlin drops a traverser whose + edge leads nowhere, so a dangling key and a key to a dead row both fail it. + `EqU32Via` alone drops only the first, so the liveness test is not + optional: removing it fails the row oracle. +- `out(rel).has(f, eq/neq)` composes to `Cond::Via` and adds + `Filter::EqU32Via` (negated for `neq`) on the target's lane. The liveness + test is emitted once per relation, wherever it is first named. +- `count()` after `out(rel)` counts anchor rows: a functional relation yields + at most one traverser per anchor row, so the anchor does not move. +- Refused by name: a second hop (`SecondHop`), `values(f)` on the target + (`ValueThroughRelation`; no sum through a key in Quack), `dedup()` on the + target (`DedupThroughRelation`; a set count), `where(method())` on the + target (`WhereThroughRelation`), and `in`/`both` (`FanOut`). +- Preparation binds the target field against the target's own basis, claims + and binder, and names errors `relation.field`. The key joins `required`, so + an unproven key refuses the method like any field it reads. +- A call names each target concept's population; another population, or + none, is `CallError::ForeignUniverse`. +- **R2IL cannot express it.** The fold band has `VIA` but no `GATHER`: it can + read a target field through a key, but not test that the key names a live + row. A method with a relation condition returns `CallError::FrontendGap { + R2il, "GATHER" }`; methods without one still run on both frontends. + +Evidence: `tests/relation.rs` (7 tests). FIXTURE: a synthetic `line -billed_to-> +partner` pair (37 partners, 4 dangling key values, every 7th partner dead, +every 13th line dead); every answer is checked against a row loop over the +field text at n ∈ {1, 64, 65, 4097}. Six disable runs, each red on the test +that names it: liveness test removed, `neq` ignored, population check removed, +key dropped from `required`, R2IL refusal turned into a silent pass, liveness +emitted per condition. + ## D-ids | D-id | scope | status | |---|---|---| -| D-CMH-G1-0 | vocabulary matrix (§4) | Shipped (this PR) | -| D-CMH-G1-1 | predicate methods + method references | Shipped (this PR) | -| D-CMH-G1-2 | typed Gremlin steps → method body | Shipped (this PR) | -| D-CMH-G1-3 | Gremlin-shaped runtime declarations | Shipped (this PR) | -| D-CMH-G1-4 | relation carrier in the `Glove`; #1311 anchor rule moved into `compose` | Queued | +| D-CMH-G1-0 | vocabulary matrix (§4) | Shipped (#1508) | +| D-CMH-G1-1 | predicate methods + method references | Shipped (#1508) | +| D-CMH-G1-2 | typed Gremlin steps → method body | Shipped (#1508) | +| D-CMH-G1-3 | Gremlin-shaped runtime declarations | Shipped (#1508) | +| D-CMH-G1-4a | functional relation carrier: `out(rel)`, `out(rel).has`, `count()` after it (§8) | In PR | +| D-CMH-G1-4 | the rest of the carrier: `in`/M:N (anchor moves to child or edge rows), `E()`, `outE/inV`, `dedup` after a fan-out | Queued | | D-CMH-G1-5 | FirstOfRun (insufficiency proof first; separate PR #3) | Queued | diff --git a/crates/lance-graph-glove-parity/examples/awareness_loop.rs b/crates/lance-graph-glove-parity/examples/awareness_loop.rs index 206e5f23d..69a435afa 100644 --- a/crates/lance-graph-glove-parity/examples/awareness_loop.rs +++ b/crates/lance-graph-glove-parity/examples/awareness_loop.rs @@ -111,6 +111,7 @@ fn main() { table: TABLE, reading: ReadingId(1), universe: Universe(42), + relations: &[], }; let table = MethodTable::prepare(&glove, &specs); let mut g = SoftwareGraph::new(); diff --git a/crates/lance-graph-glove-parity/examples/compose_cost.rs b/crates/lance-graph-glove-parity/examples/compose_cost.rs index 828774b24..eb15682b3 100644 --- a/crates/lance-graph-glove-parity/examples/compose_cost.rs +++ b/crates/lance-graph-glove-parity/examples/compose_cost.rs @@ -71,6 +71,7 @@ fn main() { table: TABLE, reading: ReadingId(1), universe: Universe(42), + relations: &[], }; let live = live_words(n); let lanes = batch.lanes(); diff --git a/crates/lance-graph-glove-parity/examples/method_cost.rs b/crates/lance-graph-glove-parity/examples/method_cost.rs index 347b97c8f..ae2d89f51 100644 --- a/crates/lance-graph-glove-parity/examples/method_cost.rs +++ b/crates/lance-graph-glove-parity/examples/method_cost.rs @@ -131,6 +131,7 @@ fn main() { table: TABLE, reading: ReadingId(1), universe: Universe(42), + relations: &[], }; let specs = [("billable_hours", spec)]; let sql = "SELECT billable_work_entry::billable_hours(cats) FROM cats"; diff --git a/crates/lance-graph-glove-parity/examples/method_load.rs b/crates/lance-graph-glove-parity/examples/method_load.rs index ea498dd76..f15969bc3 100644 --- a/crates/lance-graph-glove-parity/examples/method_load.rs +++ b/crates/lance-graph-glove-parity/examples/method_load.rs @@ -70,6 +70,7 @@ fn main() { table: TABLE, reading: ReadingId(1), universe: Universe(42), + relations: &[], }; let t1 = Instant::now(); let table = MethodTable::prepare_with(&glove, &[], &decls); diff --git a/crates/lance-graph-glove-parity/src/awareness.rs b/crates/lance-graph-glove-parity/src/awareness.rs index d0a762175..431bc07fd 100644 --- a/crates/lance-graph-glove-parity/src/awareness.rs +++ b/crates/lance-graph-glove-parity/src/awareness.rs @@ -629,6 +629,27 @@ impl SoftwareGraph { } continue; } + // A hop's key and target field belong to the glove, not + // the spec: the condition is recorded as written. + Cond::Reaches { rel } => { + let n = self.intern(Kind::Cond, format!("cond:out({rel})@r{r}")); + self.link(m, Rel::Filters, n, Tier::Declared, m, prov); + continue; + } + Cond::Via { + rel, + field, + literal, + negate, + } => { + let op = if negate { "!=" } else { "=" }; + let n = self.intern( + Kind::Cond, + format!("cond:out({rel}).{field}{op}{literal}@r{r}"), + ); + self.link(m, Rel::Filters, n, Tier::Declared, m, prov); + continue; + } }; let f = field(self, name); let n = self.intern(Kind::Cond, format!("cond:{f}{op}{lit}@r{r}")); diff --git a/crates/lance-graph-glove-parity/src/decl.rs b/crates/lance-graph-glove-parity/src/decl.rs index f98a6503c..ac233debe 100644 --- a/crates/lance-graph-glove-parity/src/decl.rs +++ b/crates/lance-graph-glove-parity/src/decl.rs @@ -58,6 +58,15 @@ enum DeclCond { }, /// A predicate method, by label; resolved at preparation. Ref(String), + /// `out(rel)`. + Reaches(String), + /// `out(rel).has(field, literal)`. + Via { + rel: String, + field: String, + literal: String, + negate: bool, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -83,6 +92,18 @@ impl MethodDecl { } => Cond::Ne { field, literal }, DeclCond::Field { field, literal, .. } => Cond::Eq { field, literal }, DeclCond::Ref(label) => Cond::Method(MethodRef::Label(label)), + DeclCond::Reaches(rel) => Cond::Reaches { rel }, + DeclCond::Via { + rel, + field, + literal, + negate, + } => Cond::Via { + rel, + field, + literal, + negate: *negate, + }, }) .collect() } @@ -650,6 +671,18 @@ fn traversal( negate: true, }, Cond::Method(MethodRef::Label(l)) => DeclCond::Ref(l.to_owned()), + Cond::Reaches { rel } => DeclCond::Reaches(rel.to_owned()), + Cond::Via { + rel, + field, + literal, + negate, + } => DeclCond::Via { + rel: rel.to_owned(), + field: field.to_owned(), + literal: literal.to_owned(), + negate, + }, Cond::Method(MethodRef::Key(_)) => unreachable!("text names methods by label"), }) .collect(); diff --git a/crates/lance-graph-glove-parity/src/gremlin.rs b/crates/lance-graph-glove-parity/src/gremlin.rs index ae83315fd..f4d117c02 100644 --- a/crates/lance-graph-glove-parity/src/gremlin.rs +++ b/crates/lance-graph-glove-parity/src/gremlin.rs @@ -23,7 +23,25 @@ //! | `values(f)` | cursor moves to the value | refused (`ValuesOfValues`) | //! | `count()` | `COUNT(*)` | refused (`CountOfValues`: `COUNT(f)` is not `COUNT(*)` under nulls) | //! | `sum()` | refused (`SumOfVertices`) | `SUM(f)` | -//! | `out` / `in` / `both` | refused (`NoRelationCarrier`): the glove declares no relation carrier | — | +//! | `out(rel)` | the cursor moves to the target; the anchor stays (below) | — | +//! | `in` / `both` | refused (`FanOut`): a fan-out moves the anchor, not carried | — | +//! +//! `out(rel)` follows a functional relation the glove carries +//! ([`Relation`](crate::method::Relation)): each anchor row names at most one +//! target, so the traversers after it are still one per anchor row, and +//! #1311's anchor rule holds. On the target: +//! +//! | step | after `out(rel)` | +//! |---|---| +//! | `has(f, eq/neq)` | a condition on the target's field, read through the key | +//! | `count()` | `COUNT(*)` of anchor rows that reach a live target | +//! | `out` | refused (`SecondHop`): a key read through a key (#1311 G1) | +//! | `values(f)` | refused (`ValueThroughRelation`): a sum through the key is not in Quack | +//! | `dedup()` | refused (`DedupThroughRelation`): distinct targets is a set count | +//! | `where(method())` | refused (`WhereThroughRelation`): a method of the target concept | +//! +//! Whether the relation exists, and which target fields it carries, is the +//! glove's to say; preparation refuses an unknown one. //! //! A traversal that ends on vertices is a predicate ([`MethodBody::Select`]); //! one that ends on a fold is a query; one that ends on `values(f)` returns a @@ -75,8 +93,16 @@ pub enum GremlinRefusal { NoStart, /// `V` names another concept than the method's. AnchorMismatch { step: u16, concept: u16 }, - /// A hop over a relation the glove declares no carrier for. - NoRelationCarrier(String), + /// `in` / `both`: a fan-out moves the anchor; not carried. + FanOut(String), + /// A hop after `out(rel)`. + SecondHop(String), + /// `values(f)` after `out(rel)` (the relation's label). + ValueThroughRelation(String), + /// `dedup()` after `out(rel)`. + DedupThroughRelation(String), + /// `where(method())` after `out(rel)`. + WhereThroughRelation(String), /// `dedup()` after `values(f)`: distinct values, a different multiset. DedupOfValues, /// `count()` after `values(f)`: `COUNT(f)`, no such fold yet. @@ -133,6 +159,8 @@ impl<'a> Composed<'a> { enum Cursor<'a> { Vertex, + /// On the target of `out(rel)`; the anchor has not moved. + Reached(&'a str), Value(&'a str), Done, } @@ -158,9 +186,41 @@ pub fn compose<'a>(concept: u16, steps: &[Step<'a>]) -> Result, Gre } cursor = match (step, cursor) { (Step::V(_), _) => return Err(GremlinRefusal::NoStart), - (Step::Out(r) | Step::In(r) | Step::Both(r), _) => { - return Err(GremlinRefusal::NoRelationCarrier(r.to_owned())) + (Step::In(r) | Step::Both(r), _) => return Err(GremlinRefusal::FanOut(r.to_owned())), + (Step::Out(r), Cursor::Vertex) => { + filter.push(Cond::Reaches { rel: r }); + Cursor::Reached(r) + } + (Step::Out(r), Cursor::Reached(_)) => { + return Err(GremlinRefusal::SecondHop(r.to_owned())) + } + (Step::Has { field, pred }, Cursor::Reached(rel)) => { + let (literal, negate) = match pred { + Pred::Eq(l) => (l, false), + Pred::Neq(l) => (l, true), + }; + filter.push(Cond::Via { + rel, + field, + literal, + negate, + }); + Cursor::Reached(rel) + } + (Step::Where(_), Cursor::Reached(rel)) => { + return Err(GremlinRefusal::WhereThroughRelation(rel.to_owned())) + } + (Step::Dedup, Cursor::Reached(rel)) => { + return Err(GremlinRefusal::DedupThroughRelation(rel.to_owned())) + } + (Step::Values(_), Cursor::Reached(rel)) => { + return Err(GremlinRefusal::ValueThroughRelation(rel.to_owned())) + } + (Step::Count, Cursor::Reached(_)) => { + folds.push(Fold::Count); + Cursor::Done } + (Step::Out(_), _) => return Err(GremlinRefusal::FilterAfterValues), (Step::Has { field, pred }, Cursor::Vertex) => { filter.push(match pred { Pred::Eq(literal) => Cond::Eq { field, literal }, diff --git a/crates/lance-graph-glove-parity/src/method.rs b/crates/lance-graph-glove-parity/src/method.rs index ddf2b1800..f5e0dc7b6 100644 --- a/crates/lance-graph-glove-parity/src/method.rs +++ b/crates/lance-graph-glove-parity/src/method.rs @@ -55,7 +55,9 @@ use lance_graph_mask_risc::{ execute_into, ExecError, Foreign, Out, Planes, Program, Scratch, Value, }; use lance_graph_quack::bind::{Binder, BoundField, FieldKind, TableId}; -use lance_graph_quack::{lower, Agg, Cmp, Col, Filter, LowerError, Mask, Query}; +use lance_graph_quack::{ + lower, Agg, Cmp, Col, Filter, ForeignLane, ForeignPlane, LowerError, Mask, Query, +}; use lance_graph_r2il::{procs, FoldDialect, FoldError, Val}; use crate::basis::{FieldMap, Grade}; @@ -135,13 +137,31 @@ pub enum Cond<'a> { /// same concept, schema and reading. Preparation replaces the reference /// with those conditions before anything binds, so a call never sees it. Method(MethodRef<'a>), + /// `out(rel)`: the row's key names a LIVE row of the relation's target. + /// A dangling key, or a key to a dead row, fails it, as a Gremlin + /// traverser with no edge is dropped. The anchor does not move: each + /// anchor row is still one traverser ([`Relation`]). + Reaches { rel: &'a str }, + /// `out(rel).has(field, = literal)`, or `!=` when `negate`: the row + /// reaches a live target (as [`Cond::Reaches`]) whose canonical `field` + /// equals the literal, or does not. `field` is a field of the TARGET + /// concept. + Via { + rel: &'a str, + field: &'a str, + literal: &'a str, + negate: bool, + }, } impl<'a> Cond<'a> { + /// The canonical field of the method's own concept the condition names. + /// A relation's key is known only to the glove, so a relation condition + /// names none here; preparation adds the key to `required`. fn field(self) -> Option<&'a str> { match self { Cond::Eq { field, .. } | Cond::Ne { field, .. } => Some(field), - Cond::Method(_) => None, + Cond::Method(_) | Cond::Reaches { .. } | Cond::Via { .. } => None, } } } @@ -340,6 +360,11 @@ pub enum PrepareError { /// A [`Cond::Method`] names a predicate written for another concept, /// basis, reading or effect than the caller. MethodRefContract(MethodKey), + /// A relation condition names a relation the glove does not carry. + UnknownRelation(String), + /// The relation supplies no foreign lane for this target field + /// (`relation.field`). + NoForeignLane(String), } /// Why a call did not run. @@ -359,11 +384,40 @@ pub enum CallError { method: Universe, planes: Universe, }, + /// The frontend has no call for a step the method needs (R2IL's fold band + /// has no `GATHER`, so it cannot test that a key names a live row). + FrontendGap { + frontend: Frontend, + missing: &'static str, + }, + /// The method reads a relation's target, and the call supplies that + /// concept's planes from another population, or none. + ForeignUniverse { + concept: u16, + method: Universe, + planes: Option, + }, Lower(LowerError), Exec(ExecError), Fold(FoldError), } +/// What a call reaches beyond its own table: the relation targets' resident +/// planes and value lanes, in the numbering the glove's [`Relation`]s +/// declare, and the population each target concept's planes hold. +pub struct Reach<'p> { + pub foreign: Foreign<'p>, + pub universes: &'p [(u16, Universe)], +} + +impl Reach<'static> { + /// No relation target. + pub const NONE: Self = Reach { + foreign: Foreign::NONE, + universes: &[], + }; +} + /// Which frontend lowers the call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Frontend { @@ -378,12 +432,27 @@ enum BoundFold { GroupSum { key: Col, val: Col, groups: u32 }, } +/// A bound condition: numbers only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BoundCond { + /// `lane = code`, or `!=` when `negate`. + Local { col: Col, code: u32, negate: bool }, + /// The key `fk` names a row of the foreign plane (the target's live rows). + Reaches { fk: Col, plane: ForeignPlane }, + /// The target's `lane` read through `fk` equals `code`, or not. + Via { + fk: Col, + lane: ForeignLane, + code: u32, + negate: bool, + }, +} + #[derive(Debug, Clone, PartialEq, Eq)] enum Bound { Query { live: Mask, - /// `(lane, code, negate)`. - conds: Vec<(Col, u32, bool)>, + conds: Vec, folds: Vec, law: OwnedLaw, /// The Quack programs, lowered once at preparation, one per fold. @@ -419,6 +488,8 @@ pub struct PreparedMethod { pub required: WideFieldMask, /// The population the method was bound against. pub universe: Universe, + /// The populations of the relation targets it reads, by concept. + reaches: Vec<(u16, Universe)>, bound: Bound, } @@ -447,20 +518,34 @@ impl PreparedMethod { } } -fn bound_queries(live: Mask, conds: &[(Col, u32, bool)], folds: &[BoundFold]) -> Vec { +fn bound_queries(live: Mask, conds: &[BoundCond], folds: &[BoundFold]) -> Vec { let filter = || { - Filter::and(std::iter::once(Filter::plane(live)).chain(conds.iter().map( - |&(col, code, negate)| { - Filter::cmp( + Filter::and( + std::iter::once(Filter::plane(live)).chain(conds.iter().map(|c| match *c { + BoundCond::Local { col, code, negate } => Filter::cmp( col, if negate { Cmp::NeU32(code) } else { Cmp::EqU32(code) }, - ) - }, - ))) + ), + BoundCond::Reaches { fk, plane } => Filter::semijoin(fk, plane), + BoundCond::Via { + fk, + lane, + code, + negate, + } => { + let eq = Filter::eq_u32_via(fk, lane, code); + if negate { + Filter::Not(Box::new(eq)) + } else { + eq + } + } + })), + ) }; folds .iter() @@ -488,6 +573,24 @@ impl PreparedMethod { planes: &Planes<'_>, universe: Universe, frontend: Frontend, + ) -> Result { + self.call_with(planes, universe, &Reach::NONE, frontend) + } + + /// Run the method over `planes`, reading relation targets from `reach`. + /// + /// # Errors + /// + /// As [`PreparedMethod::call`], plus [`CallError::ForeignUniverse`] when + /// `reach` holds a target concept's planes from another population (or + /// none), and [`CallError::FrontendGap`] when the frontend cannot express + /// a relation step. + pub fn call_with( + &self, + planes: &Planes<'_>, + universe: Universe, + reach: &Reach<'_>, + frontend: Frontend, ) -> Result { let Bound::Query { live, @@ -508,15 +611,42 @@ impl PreparedMethod { planes: universe, }); } + for &(concept, method) in &self.reaches { + let planes = reach + .universes + .iter() + .find(|(c, _)| *c == concept) + .map(|(_, u)| *u); + if planes != Some(method) { + return Err(CallError::ForeignUniverse { + concept, + method, + planes, + }); + } + } let values = match frontend { Frontend::Quack => programs .iter() .zip(folds) - .map(|(p, f)| run_quack(p, *f, planes)) + .map(|(p, f)| run_quack(p, *f, planes, &reach.foreign)) .collect::, _>>()?, Frontend::R2il => { - let conds: Vec<(u16, u32, bool)> = - conds.iter().map(|&(c, v, n)| (c.0, v, n)).collect(); + let conds = conds + .iter() + .map(|c| match *c { + BoundCond::Local { col, code, negate } => Ok((col.0, code, negate)), + // A relation condition always tests the target row's + // liveness, and R2IL's fold band has no call that + // reads a foreign plane through a key. + BoundCond::Reaches { .. } | BoundCond::Via { .. } => { + Err(CallError::FrontendGap { + frontend: Frontend::R2il, + missing: "GATHER", + }) + } + }) + .collect::, _>>()?; folds .iter() .map(|f| run_r2il(*live, &conds, *f, planes)) @@ -555,21 +685,16 @@ fn run_quack( program: &Program, fold: BoundFold, planes: &Planes<'_>, + foreign: &Foreign<'_>, ) -> Result { let mut scratch = Scratch::for_program(program, planes.n_rows).map_err(CallError::Exec)?; if let BoundFold::GroupSum { groups, .. } = fold { let mut sink = vec![0i64; groups as usize]; - execute_into( - program, - planes, - &Foreign::NONE, - &mut scratch, - Out::I64(&mut sink), - ) - .map_err(CallError::Exec)?; + execute_into(program, planes, foreign, &mut scratch, Out::I64(&mut sink)) + .map_err(CallError::Exec)?; return Ok(FoldValue::Groups(sink)); } - match execute_into(program, planes, &Foreign::NONE, &mut scratch, Out::None) + match execute_into(program, planes, foreign, &mut scratch, Out::None) .map_err(CallError::Exec)? { Value::Count(n) => Ok(FoldValue::Scalar( @@ -697,9 +822,25 @@ impl MethodTable { planes: &Planes<'_>, universe: Universe, frontend: Frontend, + ) -> Result { + self.call_with(key, planes, universe, &Reach::NONE, frontend) + } + + /// Call a method by key, reading relation targets from `reach`. + /// + /// # Errors + /// + /// As [`MethodTable::call`] and [`PreparedMethod::call_with`]. + pub fn call_with( + &self, + key: MethodKey, + planes: &Planes<'_>, + universe: Universe, + reach: &Reach<'_>, + frontend: Frontend, ) -> Result { match self.get(key).ok_or(CallError::UnknownMethod)? { - Ok(m) => m.call(planes, universe, frontend), + Ok(m) => m.call_with(planes, universe, reach, frontend), Err(e) => Err(CallError::Refused(e.clone())), } } @@ -833,6 +974,30 @@ fn available(basis: &[String], claims: &[FieldMap]) -> Result { + /// The label `out(rel)` uses. + pub name: &'a str, + /// Canonical field of THIS concept. Its lane holds target row ordinals, + /// not codebook identities, so its literals are never resolved. + pub fk: &'a str, + /// The target concept's glove. + pub target: &'a Glove<'a>, + /// `Foreign::planes` index of the target's live plane. + pub plane: u16, + /// Target canonical fields a call supplies as `Foreign::lanes`, with + /// their index. + pub lanes: &'a [(&'a str, u16)], +} + /// One glove of one concept: everything a spec is bound against. pub struct Glove<'a> { /// Canonical concept id (`ogar_vocab::class_ids`). @@ -849,6 +1014,8 @@ pub struct Glove<'a> { pub reading: ReadingId, /// The population its planes hold. pub universe: Universe, + /// The functional relations it carries. + pub relations: &'a [Relation<'a>], } impl Glove<'_> { @@ -886,7 +1053,20 @@ impl Glove<'_> { let reads = spec.reads(); let mut required = WideFieldMask::EMPTY; let mut missing = None; - for field in &reads { + let filter = match spec.body { + MethodBody::Query { filter, .. } | MethodBody::Select { filter } => filter, + MethodBody::Action { .. } => &[], + }; + let mut keys = Vec::new(); + for c in filter { + if let Cond::Reaches { rel } | Cond::Via { rel, .. } = *c { + let fk = self.relation(rel)?.fk; + if !reads.contains(&fk) && !keys.contains(&fk) { + keys.push(fk); + } + } + } + for field in reads.iter().chain(&keys) { let pos = self.position(field)?; required = required.with(pos); if missing.is_none() && !available.has(pos) { @@ -910,10 +1090,7 @@ impl Glove<'_> { .binder .table(self.table) .ok_or_else(|| PrepareError::UnknownTable(self.table.to_owned()))?; - let conds = filter - .iter() - .map(|c| self.cond(t, *c)) - .collect::, _>>()?; + let conds = self.conds(t, filter)?; let folds = folds .iter() .map(|f| match *f { @@ -947,9 +1124,7 @@ impl Glove<'_> { .binder .table(self.table) .ok_or_else(|| PrepareError::UnknownTable(self.table.to_owned()))?; - for c in filter { - self.cond(t, *c)?; - } + self.conds(t, filter)?; Bound::Select } (MethodBody::Action { .. }, EffectClass::Propose | EffectClass::ExternalMutate) => { @@ -957,15 +1132,76 @@ impl Glove<'_> { } _ => return Err(PrepareError::EffectMismatch), }; + let mut reaches: Vec<(u16, Universe)> = Vec::new(); + for c in filter { + if let Cond::Reaches { rel } | Cond::Via { rel, .. } = *c { + let r = self.relation(rel)?; + if !reaches.iter().any(|(c, _)| *c == r.target.concept) { + reaches.push((r.target.concept, r.target.universe)); + } + } + } Ok(PreparedMethod { key: spec.key, effect: spec.effect, required, universe: self.universe, + reaches, bound, }) } + fn relation(&self, name: &str) -> Result<&Relation<'_>, PrepareError> { + self.relations + .iter() + .find(|r| r.name == name) + .ok_or_else(|| PrepareError::UnknownRelation(name.to_owned())) + } + + /// Bind a filter. Each relation's liveness test is emitted once, where + /// the relation is first named. + fn conds(&self, t: TableId, filter: &[Cond<'_>]) -> Result, PrepareError> { + let mut out = Vec::with_capacity(filter.len()); + let mut reached: Vec<&str> = Vec::new(); + for c in filter { + let rel = match *c { + Cond::Reaches { rel } | Cond::Via { rel, .. } => rel, + _ => { + out.push(self.cond(t, *c)?); + continue; + } + }; + let r = self.relation(rel)?; + let fk = self.field(t, r.fk)?; + if fk.kind != FieldKind::Code { + return Err(PrepareError::KindMismatch(r.fk.to_owned())); + } + if !reached.contains(&rel) { + reached.push(rel); + out.push(BoundCond::Reaches { + fk: fk.col, + plane: ForeignPlane(r.plane), + }); + } + if let Cond::Via { + field, + literal, + negate, + .. + } = *c + { + let (lane, code) = r.bind(field, literal)?; + out.push(BoundCond::Via { + fk: fk.col, + lane, + code, + negate, + }); + } + } + Ok(out) + } + /// A required field's lane, which must be of `kind`. fn lane(&self, t: TableId, field: &str, kind: FieldKind) -> Result { let b = self.field(t, field)?; @@ -1010,10 +1246,11 @@ impl Glove<'_> { Ok(b) } - fn cond(&self, t: TableId, c: Cond<'_>) -> Result<(Col, u32, bool), PrepareError> { + fn cond(&self, t: TableId, c: Cond<'_>) -> Result { let (field, literal, negate) = match c { Cond::Eq { field, literal } => (field, literal, false), Cond::Ne { field, literal } => (field, literal, true), + Cond::Reaches { .. } | Cond::Via { .. } => unreachable!("bound by `conds`"), // Inlined before binding; a reference that reaches here was not. Cond::Method(MethodRef::Key(k)) => return Err(PrepareError::MethodRefNotPredicate(k)), Cond::Method(MethodRef::Label(l)) => { @@ -1031,7 +1268,58 @@ impl Glove<'_> { field: field.to_owned(), literal: literal.to_owned(), })?; - Ok((b.col, code, negate)) + Ok(BoundCond::Local { + col: b.col, + code, + negate, + }) + } +} + +impl Relation<'_> { + /// A target field and literal, as a foreign lane and code. Errors name + /// the field `relation.field`. + fn bind(&self, field: &str, literal: &str) -> Result<(ForeignLane, u32), PrepareError> { + let named = || format!("{}.{field}", self.name); + let g = self.target; + if !g.basis.iter().any(|b| b == field) { + return Err(PrepareError::UnknownField(named())); + } + let proven = g + .claims + .iter() + .any(|c| c.canonical == field && matches!(c.grade, Grade::Exact | Grade::Converted)); + if !proven { + return Err(PrepareError::UnmappedRequired(named())); + } + let lane = self + .lanes + .iter() + .find(|(f, _)| *f == field) + .map(|(_, l)| ForeignLane(*l)) + .ok_or_else(|| PrepareError::NoForeignLane(named()))?; + let t = g + .binder + .table(g.table) + .ok_or_else(|| PrepareError::UnknownTable(g.table.to_owned()))?; + let rename = |e: PrepareError| match e { + PrepareError::Nullable(_) => PrepareError::Nullable(named()), + PrepareError::Unbound(_) => PrepareError::Unbound(named()), + PrepareError::AmbiguousClaim(_) => PrepareError::AmbiguousClaim(named()), + other => other, + }; + let b = g.field(t, field).map_err(rename)?; + if b.kind != FieldKind::Code { + return Err(PrepareError::KindMismatch(named())); + } + let code = g + .binder + .code(t, b.col, literal) + .ok_or_else(|| PrepareError::UnknownValue { + field: named(), + literal: literal.to_owned(), + })?; + Ok((lane, code)) } } diff --git a/crates/lance-graph-glove-parity/tests/awareness.rs b/crates/lance-graph-glove-parity/tests/awareness.rs index 1dfa2dcb6..58265a961 100644 --- a/crates/lance-graph-glove-parity/tests/awareness.rs +++ b/crates/lance-graph-glove-parity/tests/awareness.rs @@ -170,6 +170,7 @@ fn glove<'a>(basis: &'a [String], binder: &'a dyn Binder, claims: &'a [FieldMap] table: TABLE, reading: READING, universe: UNIVERSE, + relations: &[], } } @@ -734,6 +735,7 @@ fn nars_table( table: EVIDENCE, reading: READING, universe: UNIVERSE, + relations: &[], }, &specs, ); diff --git a/crates/lance-graph-glove-parity/tests/common/mod.rs b/crates/lance-graph-glove-parity/tests/common/mod.rs index 53550e208..98fb483fa 100644 --- a/crates/lance-graph-glove-parity/tests/common/mod.rs +++ b/crates/lance-graph-glove-parity/tests/common/mod.rs @@ -73,6 +73,7 @@ pub fn glove<'a>(basis: &'a [String], binder: &'a dyn Binder, claims: &'a [Field table: TABLE, reading: READING, universe: UNIVERSE, + relations: &[], } } diff --git a/crates/lance-graph-glove-parity/tests/declared.rs b/crates/lance-graph-glove-parity/tests/declared.rs index 1a3649617..17bc89bc5 100644 --- a/crates/lance-graph-glove-parity/tests/declared.rs +++ b/crates/lance-graph-glove-parity/tests/declared.rs @@ -73,6 +73,7 @@ fn glove<'a>(basis: &'a [String], binder: &'a dyn Binder, claims: &'a [FieldMap] table: TABLE, reading: READING, universe: UNIVERSE, + relations: &[], } } diff --git a/crates/lance-graph-glove-parity/tests/gremlin.rs b/crates/lance-graph-glove-parity/tests/gremlin.rs index 85576efdc..6b1a29d9b 100644 --- a/crates/lance-graph-glove-parity/tests/gremlin.rs +++ b/crates/lance-graph-glove-parity/tests/gremlin.rs @@ -148,15 +148,13 @@ fn a_composed_traversal_is_the_direct_quack_query() { #[test] fn steps_outside_the_anchor_contract_are_refused() { let refuse = |steps: &[Step<'static>]| compose(BILLABLE_WORK_ENTRY, steps).unwrap_err(); - // A hop: the glove declares no relation carrier, in either direction. - for hop in [ - Step::Out("performed_by"), - Step::In("performed_by"), - Step::Both("performed_by"), - ] { + // A fan-out moves the anchor; only a functional `out` is carried + // (D-CMH-G1-4a; whether this glove carries the relation is decided at + // preparation, see `a_relation_the_glove_does_not_carry_is_refused`). + for hop in [Step::In("performed_by"), Step::Both("performed_by")] { assert_eq!( refuse(&[V, hop, Step::Count]), - GremlinRefusal::NoRelationCarrier("performed_by".into()) + GremlinRefusal::FanOut("performed_by".into()) ); } // dedup of values is a distinct-value count: a different multiset. @@ -390,8 +388,8 @@ fn effects_and_unsupported_steps_are_refused_at_load() { // Lawful steps outside the anchor contract: refused by the composer. for (body, refusal) in [ ( - format!("{head}.out(performed_by).count()"), - GremlinRefusal::NoRelationCarrier("performed_by".into()), + format!("{head}.in(performed_by).count()"), + GremlinRefusal::FanOut("performed_by".into()), ), ( format!("{head}.values(duration).dedup().count()"), @@ -417,6 +415,26 @@ fn effects_and_unsupported_steps_are_refused_at_load() { } } +/// FAILS IF: `out(rel)` over a relation the SAP glove does not carry +/// prepares. It composes (the composer knows no glove); preparation refuses. +#[test] +fn a_relation_the_glove_does_not_carry_is_refused() { + let text = line( + "m", + 0xF0, + "V(billable_work_entry).out(performed_by).count()", + ); + let decls = parse_decls(&text).unwrap(); + let batch = batch(2); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare_with(&glove(&basis, &cats, &claims), &[], &decls); + assert_eq!( + t.get(t.resolve("m").unwrap()).unwrap().clone().unwrap_err(), + PrepareError::UnknownRelation("performed_by".into()) + ); +} + /// FAILS IF: references between loaded methods bypass the preparation /// checks: a cycle, a missing predicate, a reference to a fold. #[test] diff --git a/crates/lance-graph-glove-parity/tests/method.rs b/crates/lance-graph-glove-parity/tests/method.rs index f238f8daa..1393f27b8 100644 --- a/crates/lance-graph-glove-parity/tests/method.rs +++ b/crates/lance-graph-glove-parity/tests/method.rs @@ -192,6 +192,7 @@ fn glove<'a>(basis: &'a [String], binder: &'a dyn Binder, claims: &'a [FieldMap] table: TABLE, reading: READING, universe: UNIVERSE, + relations: &[], } } diff --git a/crates/lance-graph-glove-parity/tests/polyfill.rs b/crates/lance-graph-glove-parity/tests/polyfill.rs index dd547e321..c7fc7a59a 100644 --- a/crates/lance-graph-glove-parity/tests/polyfill.rs +++ b/crates/lance-graph-glove-parity/tests/polyfill.rs @@ -104,6 +104,7 @@ fn glove<'a>(basis: &'a [String], binder: &'a dyn Binder, claims: &'a [FieldMap] table: TABLE, reading: READING, universe: UNIVERSE, + relations: &[], } } @@ -612,6 +613,7 @@ fn nars_table() -> MethodTable { table: EVIDENCE, reading: READING, universe: UNIVERSE, + relations: &[], }, &nars_specs(), ) @@ -743,6 +745,7 @@ fn a_revision_body_without_independence_or_in_the_wrong_shape_is_refused() { table: EVIDENCE, reading: READING, universe: UNIVERSE, + relations: &[], }, &specs, ); @@ -956,6 +959,7 @@ fn a_degenerate_revision_law_is_refused() { table: EVIDENCE, reading: READING, universe: UNIVERSE, + relations: &[], }, &specs, ); diff --git a/crates/lance-graph-glove-parity/tests/relation.rs b/crates/lance-graph-glove-parity/tests/relation.rs new file mode 100644 index 000000000..c7143b48a --- /dev/null +++ b/crates/lance-graph-glove-parity/tests/relation.rs @@ -0,0 +1,656 @@ +//! D-CMH-G1-4a: a functional relation carried by the glove. +//! +//! `out(rel)` over a foreign key keeps the anchor (#1311's anchor rule): each +//! anchor row still stands for one traverser, and the target's fields are +//! read through the key. Gremlin drops a traverser whose edge leads nowhere, +//! so `out(rel)` is "the key names a LIVE row of the target": a dangling key +//! and a key to a dead row both fail it. +//! +//! FIXTURE: an ERP-shaped pair (`line -billed_to-> partner`) with synthetic +//! concept ids. Every answer is checked against a row loop that reads the +//! TEXT of each field, never a bound lane or a code. +use lance_graph_glove_parity::basis::{FieldMap, Grade}; +use lance_graph_glove_parity::gremlin::{compose, GremlinRefusal, Pred, Step}; +use lance_graph_glove_parity::method::{ + Answer, CallError, Cond, EffectClass, Fold, FoldValue, Frontend, Glove, Law, MethodBody, + MethodKey, MethodRef, MethodSpec, MethodTable, PrepareError, Reach, ReadingId, Relation, + SchemaSeal, Universe, +}; +use lance_graph_mask_risc::{words_for, Foreign, ForeignPlane, LaneRef, Planes}; +use lance_graph_quack::bind::{Binder, BoundField, FieldKind, TableId}; +use lance_graph_quack::{ + lower, Agg, Cmp, Col, Filter, ForeignLane, ForeignPlane as QPlane, Mask, Query, +}; + +/// FIXTURE concept ids. +const LINE: u16 = 0xFFE1; +const PARTNER: u16 = 0xFFE2; +const READING: ReadingId = ReadingId(1); +const LINE_U: Universe = Universe(7); +const PARTNER_U: Universe = Universe(8); +const PARTNERS: usize = 37; +const SIZES: [usize; 4] = [1, 64, 65, 4097]; + +const STATUS: [&str; 3] = ["open", "posted", "void"]; +const COUNTRY: [&str; 3] = ["DE", "FR", "US"]; + +fn status(i: usize) -> &'static str { + STATUS[(i * 5 + i / 7) % 3] +} +fn amount(i: usize) -> i32 { + ((i * 37) % 201) as i32 - 100 +} +/// Some keys dangle: `PARTNERS..PARTNERS + 4` names no partner. +fn partner_of(i: usize) -> usize { + (i * 11 + 3) % (PARTNERS + 4) +} +fn line_live(i: usize) -> bool { + i % 13 != 5 +} +fn country(p: usize) -> &'static str { + COUNTRY[(p * 3 + p / 4) % 3] +} +fn partner_live(p: usize) -> bool { + p % 7 != 2 +} + +/// The binder of both tables. Codes are the text's index in its domain. +struct Erp; + +impl Binder for Erp { + fn table(&self, name: &str) -> Option { + match name { + "line" => Some(TableId(0)), + "partner" => Some(TableId(1)), + _ => None, + } + } + fn live(&self, _: TableId) -> Mask { + Mask(0) + } + fn field(&self, t: TableId, name: &str) -> Option { + let (col, kind) = match (t.0, name) { + (0, "status") => (0, FieldKind::Code), + (0, "amount") => (1, FieldKind::I32), + (0, "partner") => (2, FieldKind::Code), + (1, "country") => (0, FieldKind::Code), + (1, "credit") => (1, FieldKind::I32), + _ => return None, + }; + Some(BoundField { + col: Col(col), + kind, + validity: None, + }) + } + fn code(&self, t: TableId, col: Col, literal: &str) -> Option { + let domain: &[&str] = match (t.0, col.0) { + (0, 0) => &STATUS, + (1, 0) => &COUNTRY, + _ => return None, + }; + domain.iter().position(|d| *d == literal).map(|p| p as u32) + } +} + +fn exact(fields: &[&'static str]) -> Vec { + fields + .iter() + .map(|f| FieldMap { + native: f, + canonical: f, + grade: Grade::Exact, + note: "fixture", + }) + .collect() +} + +fn names(fields: &[&str]) -> Vec { + fields.iter().map(|f| (*f).to_owned()).collect() +} + +/// The planes of one size, owned. +struct Data { + n: usize, + line_live: Vec, + status: Vec, + amount: Vec, + partner: Vec, + partner_live: Vec, + country: Vec, + credit: Vec, +} + +fn bits(n: usize, f: impl Fn(usize) -> bool) -> Vec { + let mut w = vec![0u64; words_for(n)]; + for i in (0..n).filter(|&i| f(i)) { + w[i / 64] |= 1 << (i % 64); + } + w +} + +fn data(n: usize) -> Data { + let code = |d: &[&str], s: &str| d.iter().position(|x| *x == s).unwrap() as u32; + Data { + n, + line_live: bits(n, line_live), + status: (0..n).map(|i| code(&STATUS, status(i))).collect(), + amount: (0..n).map(amount).collect(), + partner: (0..n).map(|i| partner_of(i) as u32).collect(), + partner_live: bits(PARTNERS, partner_live), + country: (0..PARTNERS).map(|p| code(&COUNTRY, country(p))).collect(), + credit: (0..PARTNERS).map(|p| p as i32).collect(), + } +} + +impl Data { + fn run( + &self, + table: &MethodTable, + key: MethodKey, + frontend: Frontend, + ) -> Result { + self.run_as(table, key, frontend, &[(PARTNER, PARTNER_U)]) + } + + fn run_as( + &self, + table: &MethodTable, + key: MethodKey, + frontend: Frontend, + universes: &[(u16, Universe)], + ) -> Result { + let masks = [self.line_live.as_slice()]; + let lanes = [ + LaneRef::U32(&self.status), + LaneRef::I32(&self.amount), + LaneRef::U32(&self.partner), + ]; + let planes = Planes { + n_rows: self.n, + masks: &masks, + lanes: &lanes, + }; + let fplanes = [ForeignPlane { + words: &self.partner_live, + rows: PARTNERS, + }]; + let flanes = [LaneRef::U32(&self.country), LaneRef::I32(&self.credit)]; + let reach = Reach { + foreign: Foreign { + planes: &fplanes, + lanes: &flanes, + }, + universes, + }; + table.call_with(key, &planes, LINE_U, &reach, frontend) + } +} + +/// Row loop over the text: rows that are live and pass `keep`. +fn oracle(n: usize, keep: impl Fn(usize) -> bool) -> (i64, i64) { + let rows: Vec = (0..n).filter(|&i| line_live(i) && keep(i)).collect(); + ( + rows.len() as i64, + rows.iter().map(|&i| i64::from(amount(i))).sum(), + ) +} + +/// The target row a line reaches, if any: in range and live. +fn reached(i: usize) -> Option { + let p = partner_of(i); + (p < PARTNERS && partner_live(p)).then_some(p) +} + +/// A row filter of the oracle. +type Keep = Box bool>; + +struct Fx { + line_basis: Vec, + line_claims: Vec, + partner_basis: Vec, + partner_claims: Vec, +} + +impl Fx { + fn new() -> Self { + Self { + line_basis: names(&["status", "amount", "partner"]), + line_claims: exact(&["status", "amount", "partner"]), + partner_basis: names(&["country", "credit"]), + partner_claims: exact(&["country", "credit"]), + } + } + + fn spec<'b>(&self, key: u8, body: MethodBody<'b>) -> MethodSpec<'b> { + MethodSpec { + key: MethodKey([key; 16]), + concept: LINE, + schema: SchemaSeal::of(&self.line_basis), + reading: READING, + effect: EffectClass::Pure, + body, + } + } + + /// Prepare `specs` against the line glove with the `billed_to` relation. + fn table(&self, specs: &[(&str, MethodSpec<'_>)]) -> MethodTable { + self.table_with(specs, &[("country", 0), ("credit", 1)], &self.line_claims) + } + + fn table_with( + &self, + specs: &[(&str, MethodSpec<'_>)], + lanes: &[(&str, u16)], + line_claims: &[FieldMap], + ) -> MethodTable { + let partner = Glove { + concept: PARTNER, + basis: &self.partner_basis, + claims: &self.partner_claims, + binder: &Erp, + table: "partner", + reading: READING, + universe: PARTNER_U, + relations: &[], + }; + let rels = [Relation { + name: "billed_to", + fk: "partner", + target: &partner, + plane: 0, + lanes, + }]; + let glove = Glove { + concept: LINE, + basis: &self.line_basis, + claims: line_claims, + binder: &Erp, + table: "line", + reading: READING, + universe: LINE_U, + relations: &rels, + }; + MethodTable::prepare(&glove, specs) + } +} + +fn query(filter: &'static [Cond<'static>], folds: &'static [Fold<'static>]) -> MethodBody<'static> { + MethodBody::Query { + filter, + folds, + law: Law::Values, + } +} + +const COUNT_SUM: &[Fold] = &[Fold::Count, Fold::Sum("amount")]; +const OPEN: Cond = Cond::Eq { + field: "status", + literal: "open", +}; +const TO_DE: Cond = Cond::Via { + rel: "billed_to", + field: "country", + literal: "DE", + negate: false, +}; +const NOT_DE: Cond = Cond::Via { + rel: "billed_to", + field: "country", + literal: "DE", + negate: true, +}; +const REACHES: Cond = Cond::Reaches { rel: "billed_to" }; + +fn scalars(a: Answer) -> (i64, i64) { + match a { + Answer::Values(v) => match v.as_slice() { + [FoldValue::Scalar(c), FoldValue::Scalar(s)] => (*c, *s), + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + } +} + +#[test] +fn a_condition_through_the_relation_matches_the_row_loop() { + let fx = Fx::new(); + let specs = [ + ("open_to_de", fx.spec(1, query(&[OPEN, TO_DE], COUNT_SUM))), + ("open_not_de", fx.spec(2, query(&[OPEN, NOT_DE], COUNT_SUM))), + ("reaches", fx.spec(3, query(&[REACHES], COUNT_SUM))), + ]; + let table = fx.table(&specs); + for n in SIZES { + let d = data(n); + let open = |i: usize| status(i) == "open"; + let cases: [(u8, Keep); 3] = [ + ( + 1, + Box::new(move |i| open(i) && reached(i).is_some_and(|p| country(p) == "DE")), + ), + ( + 2, + Box::new(move |i| open(i) && reached(i).is_some_and(|p| country(p) != "DE")), + ), + (3, Box::new(|i| reached(i).is_some())), + ]; + for (k, keep) in cases { + let got = scalars(d.run(&table, MethodKey([k; 16]), Frontend::Quack).unwrap()); + assert_eq!(got, oracle(n, keep), "method {k}, n={n}"); + } + } + // Anti-vacuity at the largest size: each guard changes the answer. + let n = 4097; + let any = oracle(n, |_| true).0; + let in_range = oracle(n, |i| partner_of(i) < PARTNERS).0; + let reach = oracle(n, |i| reached(i).is_some()).0; + assert!( + reach < in_range && in_range < any, + "{reach} {in_range} {any}" + ); + let not_de = oracle(n, |i| reached(i).is_some_and(|p| country(p) != "DE")).0; + let not_eq_de = oracle(n, |i| { + let p = partner_of(i); + !(p < PARTNERS && country(p) == "DE") + }) + .0; + assert!( + not_de < not_eq_de, + "!= through a relation must require a live target" + ); +} + +#[test] +fn the_relation_lowers_to_the_direct_quack_query() { + let fx = Fx::new(); + let specs = [("open_to_de", fx.spec(1, query(&[OPEN, TO_DE], COUNT_SUM)))]; + let table = fx.table(&specs); + let m = table.get(MethodKey([1; 16])).unwrap().as_ref().unwrap(); + let filter = || { + Filter::and([ + Filter::plane(Mask(0)), + Filter::cmp(Col(0), Cmp::EqU32(0)), + Filter::semijoin(Col(2), QPlane(0)), + Filter::eq_u32_via(Col(2), ForeignLane(0), 0), + ]) + }; + let direct = [ + Query { + filter: filter(), + agg: Agg::Count, + }, + Query { + filter: filter(), + agg: Agg::SumI32(Col(1)), + }, + ]; + assert_eq!(m.queries(), direct); + let programs: Vec<_> = direct.iter().map(|q| lower(q).unwrap()).collect(); + assert_eq!(m.programs(), programs.as_slice()); + // The key is a field the method reads. + assert!(m.required.has(2), "the fk is required"); + // `out(rel)` before `has` adds no second liveness test. + let specs = [ + ( + "explicit", + fx.spec(1, query(&[OPEN, REACHES, TO_DE], COUNT_SUM)), + ), + ("implied", fx.spec(2, query(&[OPEN, TO_DE], COUNT_SUM))), + ]; + let t = fx.table(&specs); + let get = |k: u8| t.get(MethodKey([k; 16])).unwrap().as_ref().unwrap(); + assert_eq!(get(1).queries(), get(2).queries()); +} + +#[test] +fn r2il_names_the_missing_gather() { + let fx = Fx::new(); + let specs = [ + ("to_de", fx.spec(1, query(&[TO_DE], COUNT_SUM))), + ("open", fx.spec(2, query(&[OPEN], COUNT_SUM))), + ]; + let table = fx.table(&specs); + let d = data(65); + match d.run(&table, MethodKey([1; 16]), Frontend::R2il) { + Err(CallError::FrontendGap { + frontend: Frontend::R2il, + missing: "GATHER", + }) => {} + other => panic!("{other:?}"), + } + // Silence twin: a method that reads no relation still runs on R2IL. + let got = scalars(d.run(&table, MethodKey([2; 16]), Frontend::R2il).unwrap()); + assert_eq!(got, oracle(65, |i| status(i) == "open")); +} + +#[test] +fn a_predicate_through_the_relation_is_reused_by_reference() { + let fx = Fx::new(); + let specs = [ + ( + "billed_in_de", + fx.spec(1, MethodBody::Select { filter: &[TO_DE] }), + ), + ( + "open_de", + fx.spec( + 2, + query( + &[OPEN, Cond::Method(MethodRef::Label("billed_in_de"))], + COUNT_SUM, + ), + ), + ), + ("direct", fx.spec(3, query(&[OPEN, TO_DE], COUNT_SUM))), + ]; + let table = fx.table(&specs); + let get = |k: u8| table.get(MethodKey([k; 16])).unwrap().as_ref().unwrap(); + assert_eq!(get(2).programs(), get(3).programs()); + let d = data(4097); + assert_eq!( + scalars(d.run(&table, MethodKey([2; 16]), Frontend::Quack).unwrap()), + oracle(4097, |i| status(i) == "open" + && reached(i).is_some_and(|p| country(p) == "DE")) + ); +} + +#[test] +fn a_relation_that_cannot_bind_is_refused_by_name() { + let fx = Fx::new(); + let refused = |filter: &'static [Cond<'static>]| { + let specs = [("m", fx.spec(1, query(filter, COUNT_SUM)))]; + fx.table(&specs) + .get(MethodKey([1; 16])) + .unwrap() + .clone() + .unwrap_err() + }; + assert_eq!( + refused(&[Cond::Reaches { rel: "shipped_to" }]), + PrepareError::UnknownRelation("shipped_to".into()) + ); + assert_eq!( + refused(&[Cond::Via { + rel: "billed_to", + field: "vat_id", + literal: "x", + negate: false, + }]), + PrepareError::UnknownField("billed_to.vat_id".into()) + ); + assert_eq!( + refused(&[Cond::Via { + rel: "billed_to", + field: "country", + literal: "XX", + negate: false, + }]), + PrepareError::UnknownValue { + field: "billed_to.country".into(), + literal: "XX".into() + } + ); + assert_eq!( + refused(&[Cond::Via { + rel: "billed_to", + field: "credit", + literal: "1", + negate: false, + }]), + PrepareError::KindMismatch("billed_to.credit".into()) + ); + // A target field the relation supplies no lane for. + let specs = [("m", fx.spec(1, query(&[TO_DE], COUNT_SUM)))]; + assert_eq!( + fx.table_with(&specs, &[("credit", 1)], &fx.line_claims) + .get(MethodKey([1; 16])) + .unwrap() + .clone() + .unwrap_err(), + PrepareError::NoForeignLane("billed_to.country".into()) + ); + // The key itself needs a proven claim like any field the method reads. + let unproven: Vec = fx + .line_claims + .iter() + .map(|c| FieldMap { + grade: if c.canonical == "partner" { + Grade::Hypothesized + } else { + c.grade + }, + ..*c + }) + .collect(); + assert_eq!( + fx.table_with(&specs, &[("country", 0)], &unproven) + .get(MethodKey([1; 16])) + .unwrap() + .clone() + .unwrap_err(), + PrepareError::UnmappedRequired("partner".into()) + ); +} + +#[test] +fn the_target_population_is_checked_at_the_call() { + let fx = Fx::new(); + let specs = [("to_de", fx.spec(1, query(&[TO_DE], COUNT_SUM)))]; + let table = fx.table(&specs); + let d = data(65); + let key = MethodKey([1; 16]); + match d.run_as(&table, key, Frontend::Quack, &[]) { + Err(CallError::ForeignUniverse { + concept: PARTNER, + method: PARTNER_U, + planes: None, + }) => {} + other => panic!("{other:?}"), + } + match d.run_as(&table, key, Frontend::Quack, &[(PARTNER, Universe(99))]) { + Err(CallError::ForeignUniverse { + planes: Some(Universe(99)), + .. + }) => {} + other => panic!("{other:?}"), + } + // The plain call supplies no foreign planes. + let masks = [d.line_live.as_slice()]; + let lanes = [ + LaneRef::U32(&d.status), + LaneRef::I32(&d.amount), + LaneRef::U32(&d.partner), + ]; + let planes = Planes { + n_rows: d.n, + masks: &masks, + lanes: &lanes, + }; + match table.call(key, &planes, LINE_U, Frontend::Quack) { + Err(CallError::ForeignUniverse { planes: None, .. }) => {} + other => panic!("{other:?}"), + } +} + +#[test] +fn out_composes_and_keeps_the_anchor() { + let v = Step::V(LINE); + let c = compose( + LINE, + &[ + v, + Step::Out("billed_to"), + Step::Has { + field: "country", + pred: Pred::Eq("DE"), + }, + Step::Count, + ], + ) + .unwrap(); + assert_eq!(c.filter(), &[REACHES, TO_DE]); + assert_eq!(c.folds(), &[Fold::Count]); + let c = compose( + LINE, + &[ + v, + Step::Out("billed_to"), + Step::Has { + field: "country", + pred: Pred::Neq("DE"), + }, + ], + ) + .unwrap(); + assert_eq!(c.filter(), &[REACHES, NOT_DE]); + let refuse = |steps: &[Step<'static>]| compose(LINE, steps).unwrap_err(); + let out = Step::Out("billed_to"); + assert_eq!( + refuse(&[v, out, Step::Out("owned_by"), Step::Count]), + GremlinRefusal::SecondHop("owned_by".into()) + ); + assert_eq!( + refuse(&[v, out, Step::Values("credit"), Step::Sum]), + GremlinRefusal::ValueThroughRelation("billed_to".into()) + ); + assert_eq!( + refuse(&[v, out, Step::Dedup, Step::Count]), + GremlinRefusal::DedupThroughRelation("billed_to".into()) + ); + assert_eq!( + refuse(&[v, out, Step::Where(MethodRef::Label("m")), Step::Count]), + GremlinRefusal::WhereThroughRelation("billed_to".into()) + ); + for hop in [Step::In("billed_to"), Step::Both("billed_to")] { + assert_eq!( + refuse(&[v, hop, Step::Count]), + GremlinRefusal::FanOut("billed_to".into()) + ); + } + // The composed body prepares and answers like the hand-written one. + let fx = Fx::new(); + let composed = compose( + LINE, + &[ + v, + Step::Out("billed_to"), + Step::Has { + field: "country", + pred: Pred::Eq("DE"), + }, + Step::Count, + ], + ) + .unwrap(); + let specs = [ + ("composed", fx.spec(1, composed.body())), + ( + "direct", + fx.spec(2, query(&[REACHES, TO_DE], &[Fold::Count])), + ), + ]; + let table = fx.table(&specs); + let get = |k: u8| table.get(MethodKey([k; 16])).unwrap().as_ref().unwrap(); + assert_eq!(get(1).programs(), get(2).programs()); +}